new temporal folder to test

This commit is contained in:
José Salazar
2025-12-04 18:02:28 -05:00
parent cf18fdb16b
commit 48d86436e3
252 changed files with 120330 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Activity,
Calendar,
UserPlus,
FileText,
MessageSquare,
AlertCircle,
CheckCircle,
Search,
Filter
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import PageHeader from "../components/common/PageHeader";
const iconMap = {
calendar: Calendar,
user: UserPlus,
invoice: FileText,
message: MessageSquare,
alert: AlertCircle,
check: CheckCircle,
};
const colorMap = {
blue: "bg-blue-100 text-blue-600",
red: "bg-red-100 text-red-600",
green: "bg-green-100 text-green-600",
yellow: "bg-yellow-100 text-yellow-600",
purple: "bg-purple-100 text-purple-600",
};
// Safe date formatter
const safeFormatDistanceToNow = (dateString) => {
if (!dateString) return "Unknown time";
try {
const date = new Date(dateString);
// Check for valid date object
if (isNaN(date.getTime())) return "Unknown time";
return formatDistanceToNow(date, { addSuffix: true });
} catch {
return "Unknown time";
}
};
export default function ActivityLog() {
const [activeTab, setActiveTab] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
const queryClient = useQueryClient();
const { data: user } = useQuery({
queryKey: ['current-user-activity'],
queryFn: () => base44.auth.me(),
});
const { data: activities = [], isLoading } = useQuery({
queryKey: ['activity-logs', user?.id],
queryFn: () => base44.entities.ActivityLog.filter({ userId: user?.id }, '-created_date', 100),
enabled: !!user?.id,
initialData: [],
});
const filteredActivities = activities.filter(activity => {
const matchesSearch = !searchTerm ||
activity.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
activity.description?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesTab = activeTab === "all" ||
(activeTab === "unread" && !activity.is_read) ||
(activeTab === "read" && activity.is_read);
return matchesSearch && matchesTab;
});
const unreadCount = activities.filter(a => !a.is_read).length;
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-5xl mx-auto">
<PageHeader
title="Activity Log"
subtitle={`${activities.length} total activities • ${unreadCount} unread`}
/>
{/* Filters */}
<div className="mb-6 space-y-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="bg-white border border-slate-200">
<TabsTrigger value="all">
All <Badge variant="secondary" className="ml-2">{activities.length}</Badge>
</TabsTrigger>
<TabsTrigger value="unread">
Unread <Badge variant="secondary" className="ml-2">{unreadCount}</Badge>
</TabsTrigger>
<TabsTrigger value="read">
Read <Badge variant="secondary" className="ml-2">{activities.length - unreadCount}</Badge>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
placeholder="Search activities..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
{/* Activities List */}
<div className="space-y-3">
{filteredActivities.length > 0 ? (
filteredActivities.map((activity) => {
const Icon = iconMap[activity.icon_type] || AlertCircle;
const colorClass = colorMap[activity.icon_color] || colorMap.blue;
return (
<Card key={activity.id} className={`border-2 ${activity.is_read ? 'border-slate-200 opacity-70' : 'border-blue-200'}`}>
<CardContent className="p-6">
<div className="flex gap-4">
<div className={`w-14 h-14 rounded-full ${colorClass} flex items-center justify-center flex-shrink-0`}>
<Icon className="w-7 h-7" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2">
<h3 className="font-bold text-[#1C323E] text-lg">{activity.title}</h3>
<span className="text-sm text-slate-500 whitespace-nowrap ml-4">
{safeFormatDistanceToNow(activity.created_date)}
</span>
</div>
<p className="text-slate-600 mb-4">{activity.description}</p>
<div className="flex items-center gap-3">
{!activity.is_read && (
<Badge className="bg-blue-500 text-white">Unread</Badge>
)}
<Badge variant="outline">{activity.activity_type?.replace('_', ' ')}</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
);
})
) : (
<Card>
<CardContent className="p-12 text-center">
<Activity className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-900 mb-2">No activities found</h3>
<p className="text-slate-600">Try adjusting your filters</p>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,170 @@
import React from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ArrowLeft, Save, Loader2 } from "lucide-react";
export default function AddBusiness() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [formData, setFormData] = React.useState({
business_name: "",
contact_name: "",
email: "",
phone: "",
address: "",
notes: ""
});
const createBusinessMutation = useMutation({
mutationFn: (businessData) => base44.entities.Business.create(businessData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['businesses'] });
navigate(createPageUrl("Business"));
},
});
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = (e) => {
e.preventDefault();
createBusinessMutation.mutate(formData);
};
return (
<div className="p-4 md:p-8">
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<Button
variant="ghost"
onClick={() => navigate(createPageUrl("Business"))}
className="mb-4 hover:bg-slate-100"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Businesses
</Button>
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 mb-2">Add New Business Client</h1>
<p className="text-slate-600">Enter the business details below</p>
</div>
<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="space-y-2 md:col-span-2">
<Label htmlFor="business_name" className="text-slate-700 font-medium">Business Name *</Label>
<Input
id="business_name"
value={formData.business_name}
onChange={(e) => handleChange('business_name', e.target.value)}
required
className="border-slate-200"
placeholder="Enter business name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="contact_name" className="text-slate-700 font-medium">Contact Person</Label>
<Input
id="contact_name"
value={formData.contact_name}
onChange={(e) => handleChange('contact_name', e.target.value)}
className="border-slate-200"
placeholder="Primary contact name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone" className="text-slate-700 font-medium">Phone Number</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
className="border-slate-200"
placeholder="(555) 123-4567"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="email" className="text-slate-700 font-medium">Email</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
className="border-slate-200"
placeholder="business@example.com"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="address" className="text-slate-700 font-medium">Address</Label>
<Textarea
id="address"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
rows={3}
className="border-slate-200"
placeholder="Full address including street, city, state, and zip"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="notes" className="text-slate-700 font-medium">Notes</Label>
<Textarea
id="notes"
value={formData.notes}
onChange={(e) => handleChange('notes', e.target.value)}
rows={4}
className="border-slate-200"
placeholder="Additional notes about this business..."
/>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-3 mt-6">
<Button
type="button"
variant="outline"
onClick={() => navigate(createPageUrl("Business"))}
className="border-slate-300"
>
Cancel
</Button>
<Button
type="submit"
disabled={createBusinessMutation.isPending}
className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white shadow-lg"
>
{createBusinessMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save Business
</>
)}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,271 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Building2, ArrowLeft, Check } from "lucide-react";
import PageHeader from "../components/common/PageHeader";
import { useToast } from "@/components/ui/use-toast";
export default function AddEnterprise() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const generateEnterpriseNumber = () => {
const randomNum = Math.floor(1000 + Math.random() * 9000);
return `EN-${randomNum}`;
};
const [enterpriseData, setEnterpriseData] = useState({
enterprise_number: generateEnterpriseNumber(),
enterprise_name: "",
enterprise_code: "",
brand_family: [],
headquarters_address: "",
global_policies: {
minimum_wage: 16.50,
overtime_rules: "Time and half after 8 hours",
uniform_standards: "",
background_check_required: true
},
rate_guardrails: {
minimum_markup_percentage: 15,
maximum_markup_percentage: 35,
minimum_bill_rate: 18.00
},
primary_contact_name: "",
primary_contact_email: "",
is_active: true
});
const [brandInput, setBrandInput] = useState("");
const createEnterpriseMutation = useMutation({
mutationFn: (data) => base44.entities.Enterprise.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['enterprises'] });
toast({
title: "Enterprise Created",
description: "Enterprise has been successfully added to the platform",
});
navigate(createPageUrl("EnterpriseManagement"));
},
});
const handleAddBrand = () => {
if (brandInput.trim()) {
setEnterpriseData({
...enterpriseData,
brand_family: [...enterpriseData.brand_family, brandInput.trim()]
});
setBrandInput("");
}
};
const handleRemoveBrand = (index) => {
setEnterpriseData({
...enterpriseData,
brand_family: enterpriseData.brand_family.filter((_, i) => i !== index)
});
};
const handleSubmit = (e) => {
e.preventDefault();
createEnterpriseMutation.mutate(enterpriseData);
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-4xl mx-auto">
<PageHeader
title="Add Enterprise"
subtitle="Add a new enterprise operator to the platform"
backTo={createPageUrl("EnterpriseManagement")}
backButtonLabel="Back to Enterprises"
/>
<form onSubmit={handleSubmit}>
<Card className="mb-6 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle className="flex items-center gap-2">
<Building2 className="w-5 h-5 text-[#0A39DF]" />
Basic Information
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label htmlFor="enterprise_number">Enterprise Number</Label>
<Input
id="enterprise_number"
value={enterpriseData.enterprise_number}
readOnly
className="bg-slate-50 font-mono"
/>
<p className="text-xs text-slate-500 mt-1">Auto-generated unique ID</p>
</div>
<div>
<Label htmlFor="enterprise_code">Enterprise Code *</Label>
<Input
id="enterprise_code"
placeholder="e.g., COMP"
value={enterpriseData.enterprise_code}
onChange={(e) => setEnterpriseData({...enterpriseData, enterprise_code: e.target.value.toUpperCase()})}
required
/>
</div>
<div className="md:col-span-2">
<Label htmlFor="enterprise_name">Enterprise Name *</Label>
<Input
id="enterprise_name"
placeholder="e.g., Compass Group"
value={enterpriseData.enterprise_name}
onChange={(e) => setEnterpriseData({...enterpriseData, enterprise_name: e.target.value})}
required
/>
</div>
<div>
<Label htmlFor="primary_contact_name">Primary Contact Name</Label>
<Input
id="primary_contact_name"
placeholder="John Doe"
value={enterpriseData.primary_contact_name}
onChange={(e) => setEnterpriseData({...enterpriseData, primary_contact_name: e.target.value})}
/>
</div>
<div>
<Label htmlFor="primary_contact_email">Primary Contact Email</Label>
<Input
id="primary_contact_email"
type="email"
placeholder="john@enterprise.com"
value={enterpriseData.primary_contact_email}
onChange={(e) => setEnterpriseData({...enterpriseData, primary_contact_email: e.target.value})}
/>
</div>
</div>
<div>
<Label htmlFor="headquarters_address">Headquarters Address</Label>
<Textarea
id="headquarters_address"
placeholder="123 Main St, City, State ZIP"
value={enterpriseData.headquarters_address}
onChange={(e) => setEnterpriseData({...enterpriseData, headquarters_address: e.target.value})}
rows={2}
/>
</div>
<div>
<Label>Brand Family</Label>
<div className="flex gap-2 mb-2">
<Input
placeholder="Add brand (e.g., Bon Appétit)"
value={brandInput}
onChange={(e) => setBrandInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddBrand();
}
}}
/>
<Button type="button" onClick={handleAddBrand} variant="outline">
Add
</Button>
</div>
<div className="flex flex-wrap gap-2">
{enterpriseData.brand_family.map((brand, index) => (
<div key={index} className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full flex items-center gap-2">
<span>{brand}</span>
<button
type="button"
onClick={() => handleRemoveBrand(index)}
className="text-blue-600 hover:text-blue-800"
>
×
</button>
</div>
))}
</div>
</div>
</CardContent>
</Card>
<Card className="mb-6 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle>Rate Guardrails</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Minimum Markup %</Label>
<Input
type="number"
value={enterpriseData.rate_guardrails.minimum_markup_percentage}
onChange={(e) => setEnterpriseData({
...enterpriseData,
rate_guardrails: {...enterpriseData.rate_guardrails, minimum_markup_percentage: parseFloat(e.target.value)}
})}
/>
</div>
<div>
<Label>Maximum Markup %</Label>
<Input
type="number"
value={enterpriseData.rate_guardrails.maximum_markup_percentage}
onChange={(e) => setEnterpriseData({
...enterpriseData,
rate_guardrails: {...enterpriseData.rate_guardrails, maximum_markup_percentage: parseFloat(e.target.value)}
})}
/>
</div>
<div>
<Label>Minimum Bill Rate</Label>
<Input
type="number"
step="0.01"
value={enterpriseData.rate_guardrails.minimum_bill_rate}
onChange={(e) => setEnterpriseData({
...enterpriseData,
rate_guardrails: {...enterpriseData.rate_guardrails, minimum_bill_rate: parseFloat(e.target.value)}
})}
/>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate(createPageUrl("EnterpriseManagement"))}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
disabled={createEnterpriseMutation.isPending}
>
<Check className="w-4 h-4 mr-2" />
{createEnterpriseMutation.isPending ? "Creating..." : "Create Enterprise"}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,319 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Briefcase, ArrowLeft, Check, Plus, X } from "lucide-react";
import PageHeader from "../components/common/PageHeader";
import { useToast } from "@/components/ui/use-toast";
export default function AddPartner() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const { data: sectors = [] } = useQuery({
queryKey: ['sectors'],
queryFn: () => base44.entities.Sector.list(),
initialData: [],
});
const generatePartnerNumber = () => {
const randomNum = Math.floor(1000 + Math.random() * 9000);
return `PN-${randomNum}`;
};
const [partnerData, setPartnerData] = useState({
partner_name: "",
partner_number: generatePartnerNumber(),
partner_type: "Corporate",
sector_id: "",
sector_name: "",
primary_contact_name: "",
primary_contact_email: "",
primary_contact_phone: "",
billing_address: "",
sites: [],
payment_terms: "Net 30",
is_active: true
});
const [newSite, setNewSite] = useState({
site_name: "",
address: "",
city: "",
state: "",
zip_code: "",
site_manager: "",
site_manager_email: ""
});
const createPartnerMutation = useMutation({
mutationFn: (data) => base44.entities.Partner.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['partners'] });
toast({
title: "Partner Created",
description: "Partner has been successfully added to the platform",
});
navigate(createPageUrl("PartnerManagement"));
},
});
const handleSubmit = (e) => {
e.preventDefault();
createPartnerMutation.mutate(partnerData);
};
const handleSectorChange = (sectorId) => {
const sector = sectors.find(s => s.id === sectorId);
setPartnerData({
...partnerData,
sector_id: sectorId,
sector_name: sector?.sector_name || ""
});
};
const handleAddSite = () => {
if (newSite.site_name && newSite.address) {
setPartnerData({
...partnerData,
sites: [...partnerData.sites, newSite]
});
setNewSite({
site_name: "",
address: "",
city: "",
state: "",
zip_code: "",
site_manager: "",
site_manager_email: ""
});
}
};
const handleRemoveSite = (index) => {
setPartnerData({
...partnerData,
sites: partnerData.sites.filter((_, i) => i !== index)
});
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-4xl mx-auto">
<PageHeader
title="Add Partner"
subtitle="Add a new client partner to the platform"
backTo={createPageUrl("PartnerManagement")}
backButtonLabel="Back to Partners"
/>
<form onSubmit={handleSubmit}>
<Card className="mb-6 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle className="flex items-center gap-2">
<Briefcase className="w-5 h-5 text-[#0A39DF]" />
Basic Information
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label htmlFor="partner_number">Partner Number</Label>
<Input
id="partner_number"
value={partnerData.partner_number}
readOnly
className="bg-slate-50 font-mono"
/>
<p className="text-xs text-slate-500 mt-1">Auto-generated unique ID</p>
</div>
<div>
<Label htmlFor="partner_type">Partner Type *</Label>
<Select onValueChange={(value) => setPartnerData({...partnerData, partner_type: value})} value={partnerData.partner_type}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Corporate">Corporate</SelectItem>
<SelectItem value="Education">Education</SelectItem>
<SelectItem value="Healthcare">Healthcare</SelectItem>
<SelectItem value="Sports & Entertainment">Sports & Entertainment</SelectItem>
<SelectItem value="Government">Government</SelectItem>
</SelectContent>
</Select>
</div>
<div className="md:col-span-2">
<Label htmlFor="partner_name">Partner Name *</Label>
<Input
id="partner_name"
placeholder="e.g., Google"
value={partnerData.partner_name}
onChange={(e) => setPartnerData({...partnerData, partner_name: e.target.value})}
required
/>
</div>
<div>
<Label htmlFor="sector">Sector</Label>
<Select onValueChange={handleSectorChange} value={partnerData.sector_id}>
<SelectTrigger>
<SelectValue placeholder="Select sector" />
</SelectTrigger>
<SelectContent>
{sectors.map((sector) => (
<SelectItem key={sector.id} value={sector.id}>
{sector.sector_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="payment_terms">Payment Terms</Label>
<Input
id="payment_terms"
placeholder="Net 30"
value={partnerData.payment_terms}
onChange={(e) => setPartnerData({...partnerData, payment_terms: e.target.value})}
/>
</div>
<div>
<Label htmlFor="primary_contact_name">Primary Contact Name</Label>
<Input
id="primary_contact_name"
placeholder="John Doe"
value={partnerData.primary_contact_name}
onChange={(e) => setPartnerData({...partnerData, primary_contact_name: e.target.value})}
/>
</div>
<div>
<Label htmlFor="primary_contact_email">Primary Contact Email</Label>
<Input
id="primary_contact_email"
type="email"
placeholder="john@company.com"
value={partnerData.primary_contact_email}
onChange={(e) => setPartnerData({...partnerData, primary_contact_email: e.target.value})}
/>
</div>
<div>
<Label htmlFor="primary_contact_phone">Primary Contact Phone</Label>
<Input
id="primary_contact_phone"
placeholder="(555) 123-4567"
value={partnerData.primary_contact_phone}
onChange={(e) => setPartnerData({...partnerData, primary_contact_phone: e.target.value})}
/>
</div>
</div>
<div>
<Label htmlFor="billing_address">Billing Address</Label>
<Textarea
id="billing_address"
placeholder="123 Main St, City, State ZIP"
value={partnerData.billing_address}
onChange={(e) => setPartnerData({...partnerData, billing_address: e.target.value})}
rows={2}
/>
</div>
</CardContent>
</Card>
<Card className="mb-6 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle>Sites/Locations</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-slate-50 rounded-lg">
<Input
placeholder="Site Name"
value={newSite.site_name}
onChange={(e) => setNewSite({...newSite, site_name: e.target.value})}
/>
<Input
placeholder="Address"
value={newSite.address}
onChange={(e) => setNewSite({...newSite, address: e.target.value})}
/>
<Input
placeholder="City"
value={newSite.city}
onChange={(e) => setNewSite({...newSite, city: e.target.value})}
/>
<Input
placeholder="State"
value={newSite.state}
onChange={(e) => setNewSite({...newSite, state: e.target.value})}
/>
<Input
placeholder="ZIP Code"
value={newSite.zip_code}
onChange={(e) => setNewSite({...newSite, zip_code: e.target.value})}
/>
<Button type="button" onClick={handleAddSite} variant="outline" className="w-full">
<Plus className="w-4 h-4 mr-2" />
Add Site
</Button>
</div>
{partnerData.sites.length > 0 && (
<div className="space-y-2">
{partnerData.sites.map((site, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-white border rounded-lg">
<div>
<p className="font-semibold">{site.site_name}</p>
<p className="text-sm text-slate-500">{site.address}, {site.city}, {site.state} {site.zip_code}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveSite(index)}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate(createPageUrl("PartnerManagement"))}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
disabled={createPartnerMutation.isPending}
>
<Check className="w-4 h-4 mr-2" />
{createPartnerMutation.isPending ? "Creating..." : "Create Partner"}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MapPin, ArrowLeft, Check } from "lucide-react";
import PageHeader from "../components/common/PageHeader";
import { useToast } from "@/components/ui/use-toast";
export default function AddSector() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const { data: enterprises = [] } = useQuery({
queryKey: ['enterprises'],
queryFn: () => base44.entities.Enterprise.list(),
initialData: [],
});
const generateSectorNumber = () => {
const randomNum = Math.floor(1000 + Math.random() * 9000);
return `SN-${randomNum}`;
};
const [sectorData, setSectorData] = useState({
sector_number: generateSectorNumber(),
sector_name: "",
sector_code: "",
parent_enterprise_id: "",
parent_enterprise_name: "",
sector_type: "Food Service",
sector_policies: {
uniform_requirements: "",
certification_requirements: [],
special_training: []
},
is_active: true
});
const createSectorMutation = useMutation({
mutationFn: (data) => base44.entities.Sector.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sectors'] });
toast({
title: "Sector Created",
description: "Sector has been successfully added to the platform",
});
navigate(createPageUrl("SectorManagement"));
},
});
const handleSubmit = (e) => {
e.preventDefault();
createSectorMutation.mutate(sectorData);
};
const handleEnterpriseChange = (enterpriseId) => {
const enterprise = enterprises.find(e => e.id === enterpriseId);
setSectorData({
...sectorData,
parent_enterprise_id: enterpriseId,
parent_enterprise_name: enterprise?.enterprise_name || ""
});
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-4xl mx-auto">
<PageHeader
title="Add Sector"
subtitle="Add a new operating brand/sector to the platform"
backTo={createPageUrl("SectorManagement")}
backButtonLabel="Back to Sectors"
/>
<form onSubmit={handleSubmit}>
<Card className="mb-6 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-[#0A39DF]" />
Basic Information
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label htmlFor="sector_number">Sector Number</Label>
<Input
id="sector_number"
value={sectorData.sector_number}
readOnly
className="bg-slate-50 font-mono"
/>
<p className="text-xs text-slate-500 mt-1">Auto-generated unique ID</p>
</div>
<div>
<Label htmlFor="sector_code">Sector Code *</Label>
<Input
id="sector_code"
placeholder="e.g., BON"
value={sectorData.sector_code}
onChange={(e) => setSectorData({...sectorData, sector_code: e.target.value.toUpperCase()})}
required
/>
</div>
<div className="md:col-span-2">
<Label htmlFor="sector_name">Sector Name *</Label>
<Input
id="sector_name"
placeholder="e.g., Bon Appétit"
value={sectorData.sector_name}
onChange={(e) => setSectorData({...sectorData, sector_name: e.target.value})}
required
/>
</div>
<div>
<Label htmlFor="parent_enterprise">Parent Enterprise</Label>
<Select onValueChange={handleEnterpriseChange} value={sectorData.parent_enterprise_id}>
<SelectTrigger>
<SelectValue placeholder="Select enterprise" />
</SelectTrigger>
<SelectContent>
{enterprises.map((enterprise) => (
<SelectItem key={enterprise.id} value={enterprise.id}>
{enterprise.enterprise_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="sector_type">Sector Type *</Label>
<Select onValueChange={(value) => setSectorData({...sectorData, sector_type: value})} value={sectorData.sector_type}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Food Service">Food Service</SelectItem>
<SelectItem value="Facilities">Facilities</SelectItem>
<SelectItem value="Healthcare">Healthcare</SelectItem>
<SelectItem value="Education">Education</SelectItem>
<SelectItem value="Corporate">Corporate</SelectItem>
<SelectItem value="Sports & Entertainment">Sports & Entertainment</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="uniform_requirements">Uniform Requirements</Label>
<Input
id="uniform_requirements"
placeholder="e.g., Black chef coat, black pants, non-slip shoes"
value={sectorData.sector_policies.uniform_requirements}
onChange={(e) => setSectorData({
...sectorData,
sector_policies: {...sectorData.sector_policies, uniform_requirements: e.target.value}
})}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate(createPageUrl("SectorManagement"))}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
disabled={createSectorMutation.isPending}
>
<Check className="w-4 h-4 mr-2" />
{createSectorMutation.isPending ? "Creating..." : "Create Sector"}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import StaffForm from "../components/staff/StaffForm";
export default function AddStaff() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const createStaffMutation = useMutation({
mutationFn: (staffData) => base44.entities.Staff.create(staffData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['staff'] });
navigate(createPageUrl("Dashboard"));
},
});
const handleSubmit = (staffData) => {
createStaffMutation.mutate(staffData);
};
return (
<div className="p-4 md:p-8">
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<Button
variant="ghost"
onClick={() => navigate(createPageUrl("Dashboard"))}
className="mb-4 hover:bg-slate-100"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Dashboard
</Button>
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 mb-2">Add New Staff Member</h1>
<p className="text-slate-600">Fill in the details to add a new team member</p>
</div>
<StaffForm
onSubmit={handleSubmit}
isSubmitting={createStaffMutation.isPending}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,723 @@
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus, Building2, Mail, Phone, MapPin, Search, Eye, Trash2, ChevronDown, ChevronRight, Users, Star, Ban, Briefcase, Settings, UserPlus, UserMinus, Shield, Edit2, TrendingUp, Clock, Award, Grid3x3, List } from "lucide-react";
import BusinessCard from "../components/business/BusinessCard";
import { Link, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import PageHeader from "../components/common/PageHeader";
import CreateBusinessModal from "../components/business/CreateBusinessModal";
export default function Business() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [createModalOpen, setCreateModalOpen] = useState(false);
const [filterManager, setFilterManager] = useState("all");
const [filterHub, setFilterHub] = useState("all");
const [filterGrade, setFilterGrade] = useState("all");
const [filterCancelRate, setFilterCancelRate] = useState("all");
const [viewMode, setViewMode] = useState("grid"); // grid or list
const { data: user } = useQuery({
queryKey: ['current-user-business'],
queryFn: () => base44.auth.me(),
});
const { data: businesses, isLoading } = useQuery({
queryKey: ['businesses'],
queryFn: () => base44.entities.Business.list('-created_date'),
initialData: [],
});
const { data: allStaff = [] } = useQuery({
queryKey: ['staff-for-business'],
queryFn: () => base44.entities.Staff.list(),
});
const { data: allEvents = [] } = useQuery({
queryKey: ['events-for-business-metrics'],
queryFn: () => base44.entities.Event.list(),
initialData: [],
});
const { data: invoices = [] } = useQuery({
queryKey: ['invoices-for-business'],
queryFn: () => base44.entities.Invoice.list(),
initialData: [],
});
const userRole = user?.user_role || user?.role || "admin";
const isVendor = userRole === "vendor";
const createBusinessMutation = useMutation({
mutationFn: (businessData) => base44.entities.Business.create(businessData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['businesses'] });
setCreateModalOpen(false);
},
});
const updateBusinessMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Business.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['businesses'] });
},
});
const handleCreateBusiness = (businessData) => {
createBusinessMutation.mutate(businessData);
};
const handleAddFavoriteStaff = async (businessId, staff) => {
const business = businesses.find(b => b.id === businessId);
const favoriteStaff = business.favorite_staff || [];
const alreadyFavorite = favoriteStaff.some(s => s.staff_id === staff.id);
if (alreadyFavorite) return;
const updatedFavorites = [
...favoriteStaff,
{
staff_id: staff.id,
staff_name: staff.employee_name,
position: staff.position,
added_date: new Date().toISOString(),
}
];
await updateBusinessMutation.mutateAsync({
id: businessId,
data: {
favorite_staff: updatedFavorites,
favorite_staff_count: updatedFavorites.length,
}
});
};
const handleRemoveFavoriteStaff = async (businessId, staffId) => {
const business = businesses.find(b => b.id === businessId);
const favoriteStaff = business.favorite_staff || [];
const updatedFavorites = favoriteStaff.filter(s => s.staff_id !== staffId);
await updateBusinessMutation.mutateAsync({
id: businessId,
data: {
favorite_staff: updatedFavorites,
favorite_staff_count: updatedFavorites.length,
}
});
};
const handleBlockStaff = async (businessId, staff, reason) => {
const business = businesses.find(b => b.id === businessId);
const blockedStaff = business.blocked_staff || [];
const alreadyBlocked = blockedStaff.some(s => s.staff_id === staff.id);
if (alreadyBlocked) return;
const updatedBlocked = [
...blockedStaff,
{
staff_id: staff.id,
staff_name: staff.employee_name,
reason: reason || "No reason provided",
blocked_date: new Date().toISOString(),
}
];
await updateBusinessMutation.mutateAsync({
id: businessId,
data: {
blocked_staff: updatedBlocked,
blocked_staff_count: updatedBlocked.length,
}
});
};
const handleUnblockStaff = async (businessId, staffId) => {
const business = businesses.find(b => b.id === businessId);
const blockedStaff = business.blocked_staff || [];
const updatedBlocked = blockedStaff.filter(s => s.staff_id !== staffId);
await updateBusinessMutation.mutateAsync({
id: businessId,
data: {
blocked_staff: updatedBlocked,
blocked_staff_count: updatedBlocked.length,
}
});
};
// Consolidate businesses by company name
const consolidatedBusinesses = useMemo(() => {
const grouped = {};
businesses.forEach(business => {
// Extract company name (remove hub suffix if present)
let companyName = business.business_name;
// Handle cases like "Google - BVG300" or "Nvidia - Building S"
const dashIndex = companyName.indexOf(' - ');
if (dashIndex > 0) {
companyName = companyName.substring(0, dashIndex).trim();
}
// Initialize company if not exists
if (!grouped[companyName]) {
grouped[companyName] = {
company_name: companyName,
hubs: [],
primary_contact: business.contact_name,
primary_email: business.email,
primary_phone: business.phone,
sector: business.notes?.includes('Sector:') ? business.notes.split('Sector:')[1].trim() : '',
};
}
// Add hub
grouped[companyName].hubs.push({
id: business.id,
hub_name: business.business_name,
contact_name: business.contact_name,
email: business.email,
phone: business.phone,
address: business.address,
city: business.city,
notes: business.notes,
});
});
return Object.values(grouped);
}, [businesses]);
// Get unique managers and hubs for filters
const allManagers = [...new Set(consolidatedBusinesses.flatMap(c => c.hubs.map(h => h.contact_name).filter(Boolean)))];
const allHubs = [...new Set(consolidatedBusinesses.flatMap(c => c.hubs.map(h => h.hub_name)))];
const filteredBusinesses = consolidatedBusinesses.filter(company => {
const matchesSearch = !searchTerm ||
company.company_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
company.hubs.some(hub =>
hub.hub_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
hub.contact_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
hub.address?.toLowerCase().includes(searchTerm.toLowerCase())
);
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 canAddBusiness = ["admin", "procurement", "operator", "vendor"].includes(userRole);
const totalHubs = filteredBusinesses.reduce((sum, company) => sum + company.hubs.length, 0);
// Calculate KPIs
const totalCompanies = filteredBusinesses.length;
const goldClients = filteredBusinesses.filter(company => {
const clientEvents = allEvents.filter(e =>
e.business_name === company.company_name ||
company.hubs.some(h => h.hub_name === e.business_name)
);
const totalOrders = clientEvents.length;
const canceledOrders = clientEvents.filter(e => e.status === 'Canceled').length;
const cancelationRate = totalOrders > 0 ? ((canceledOrders / totalOrders) * 100) : 0;
const totalStaffRequested = clientEvents.reduce((sum, e) => sum + (e.requested || 0), 0);
const totalStaffAssigned = clientEvents.reduce((sum, e) => sum + (e.assigned_staff?.length || 0), 0);
const fillRate = totalStaffRequested > 0 ? ((totalStaffAssigned / totalStaffRequested) * 100) : 0;
const onTimeOrders = clientEvents.filter(e => {
const eventDate = new Date(e.date);
const createdDate = new Date(e.created_date);
const daysDiff = (eventDate - createdDate) / (1000 * 60 * 60 * 24);
return daysDiff >= 3;
}).length;
const onTimeRate = totalOrders > 0 ? ((onTimeOrders / totalOrders) * 100) : 0;
const metrics = [parseFloat(fillRate), parseFloat(onTimeRate), 100 - parseFloat(cancelationRate), Math.min((totalOrders / 10) * 100, 100)];
const avgScore = metrics.reduce((a, b) => a + b, 0) / metrics.length;
return avgScore >= 90;
}).length;
const totalMonthlySpend = filteredBusinesses.reduce((sum, company) => {
const clientInvoices = invoices.filter(i =>
i.business_name === company.company_name ||
company.hubs.some(h => h.hub_name === i.business_name)
);
const monthlySpend = clientInvoices
.filter(i => {
const invoiceDate = new Date(i.created_date);
const now = new Date();
return invoiceDate.getMonth() === now.getMonth() && invoiceDate.getFullYear() === now.getFullYear();
})
.reduce((s, i) => s + (i.amount || 0), 0);
return sum + monthlySpend;
}, 0);
const totalOrders = filteredBusinesses.reduce((sum, company) => {
const clientEvents = allEvents.filter(e =>
e.business_name === company.company_name ||
company.hubs.some(h => h.hub_name === e.business_name)
);
return sum + clientEvents.length;
}, 0);
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<PageHeader
title="Business Directory"
subtitle="Manage and monitor all business clients and their performance"
actions={
canAddBusiness ? (
<Button
onClick={() => setCreateModalOpen(true)}
className="bg-blue-600 hover:bg-blue-700 text-white shadow-lg"
>
<Plus className="w-5 h-5 mr-2" />
Add Business
</Button>
) : null
}
/>
{/* KPI Dashboard */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="bg-gradient-to-br from-blue-600 to-blue-700 border-0 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-blue-100 text-sm font-medium mb-1">Total Companies</p>
<p className="text-4xl font-bold text-white">{totalCompanies}</p>
</div>
<div className="w-14 h-14 bg-white/20 rounded-xl flex items-center justify-center">
<Building2 className="w-8 h-8 text-white" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-amber-500 to-yellow-600 border-0 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-amber-100 text-sm font-medium mb-1">Gold Clients</p>
<p className="text-4xl font-bold text-white">{goldClients}</p>
<p className="text-xs text-amber-100 mt-1">A+ Performance</p>
</div>
<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>
</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>
{/* Filters Section */}
<div className="mb-6 space-y-4">
{/* Search Bar */}
<div className="relative">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
placeholder="Search companies, hubs, contacts, or addresses..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-12 h-12 bg-white border-slate-300 text-base shadow-sm"
/>
</div>
{/* Filter Pills */}
<div className="flex flex-wrap gap-3">
<select
value={filterManager}
onChange={(e) => setFilterManager(e.target.value)}
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"
>
<option value="all">All Managers</option>
{allManagers.map(manager => (
<option key={manager} value={manager}>{manager}</option>
))}
</select>
<select
value={filterHub}
onChange={(e) => setFilterHub(e.target.value)}
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"
>
<option value="all">All Hubs</option>
{allHubs.map(hub => (
<option key={hub} value={hub}>{hub}</option>
))}
</select>
{isVendor && (
<>
<select
value={filterGrade}
onChange={(e) => setFilterGrade(e.target.value)}
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"
>
<option value="all">All Grades</option>
<option value="A+">A+ Grade</option>
<option value="A">A Grade</option>
<option value="A-">A- Grade</option>
<option value="B+">B+ Grade</option>
<option value="B">B Grade</option>
<option value="C">C Grade</option>
</select>
<select
value={filterCancelRate}
onChange={(e) => setFilterCancelRate(e.target.value)}
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"
>
<option value="all">All Cancellation Rates</option>
<option value="low">Low (&lt;5%)</option>
<option value="medium">Medium (5-15%)</option>
<option value="high">High (&gt;15%)</option>
</select>
</>
)}
{(searchTerm || filterManager !== "all" || filterHub !== "all" || filterGrade !== "all" || filterCancelRate !== "all") && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchTerm("");
setFilterManager("all");
setFilterHub("all");
setFilterGrade("all");
setFilterCancelRate("all");
}}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
>
Clear Filters
</Button>
)}
</div>
{/* View Mode Toggle */}
<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>
{/* 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 className="text-center py-16 bg-white rounded-xl border border-slate-200">
<Building2 className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-900 mb-2">
{searchTerm ? "No businesses found" : "No business clients yet"}
</h3>
<p className="text-slate-600 mb-6">
{searchTerm ? "Try adjusting your search" : "Get started by adding your first business"}
</p>
{canAddBusiness && !searchTerm && (
<Button
onClick={() => setCreateModalOpen(true)}
className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white"
>
<Plus className="w-4 h-4 mr-2" />
Add First Business
</Button>
)}
</div>
)}
</div>
{/* Create Business Modal */}
<CreateBusinessModal
open={createModalOpen}
onOpenChange={setCreateModalOpen}
onSubmit={handleCreateBusiness}
isSubmitting={createBusinessMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import React from "react";
import { Award } from "lucide-react";
export default function Certification() {
return (
<div className="p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-3 mb-8">
<Award className="w-8 h-8" />
<h1 className="text-3xl font-bold">Certification</h1>
</div>
<div className="text-center py-16 bg-white rounded-xl border">
<Award className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<p className="text-slate-600">Certification management coming soon</p>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,126 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { FileText, Download, DollarSign, Clock, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function ClientInvoices() {
const invoices = [
{ id: "INV-001", event: "Tech Conference 2025", amount: 12400, status: "Paid", date: "2025-01-10", dueDate: "2025-01-20" },
{ id: "INV-002", event: "Product Launch", amount: 8950, status: "Pending", date: "2025-01-15", dueDate: "2025-01-25" },
{ id: "INV-003", event: "Annual Gala", amount: 15200, status: "Overdue", date: "2024-12-20", dueDate: "2025-01-05" },
];
const stats = {
total: invoices.reduce((sum, inv) => sum + inv.amount, 0),
paid: invoices.filter(i => i.status === "Paid").length,
pending: invoices.filter(i => i.status === "Pending").length,
overdue: invoices.filter(i => i.status === "Overdue").length,
};
const getStatusColor = (status) => {
const colors = {
'Paid': 'bg-green-100 text-green-700',
'Pending': 'bg-yellow-100 text-yellow-700',
'Overdue': 'bg-red-100 text-red-700',
};
return colors[status] || 'bg-slate-100 text-slate-700';
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold text-[#1C323E]">My Invoices</h1>
<p className="text-slate-500 mt-1">View and manage your billing</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card className="border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<DollarSign className="w-8 h-8 text-[#0A39DF]" />
</div>
<p className="text-sm text-slate-500">Total Amount</p>
<p className="text-3xl font-bold text-[#1C323E]">${stats.total.toLocaleString()}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<CheckCircle2 className="w-8 h-8 text-green-600" />
</div>
<p className="text-sm text-slate-500">Paid</p>
<p className="text-3xl font-bold text-green-600">{stats.paid}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<Clock className="w-8 h-8 text-yellow-600" />
</div>
<p className="text-sm text-slate-500">Pending</p>
<p className="text-3xl font-bold text-yellow-600">{stats.pending}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<FileText className="w-8 h-8 text-red-600" />
</div>
<p className="text-sm text-slate-500">Overdue</p>
<p className="text-3xl font-bold text-red-600">{stats.overdue}</p>
</CardContent>
</Card>
</div>
{/* Invoices List */}
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle>All Invoices</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{invoices.map((invoice) => (
<div key={invoice.id} className="p-6 bg-white border-2 border-slate-200 rounded-xl hover:border-[#0A39DF] transition-all">
<div className="flex items-center justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold text-[#1C323E]">{invoice.id}</h3>
<Badge className={getStatusColor(invoice.status)}>
{invoice.status}
</Badge>
</div>
<p className="text-sm text-slate-600">{invoice.event}</p>
<p className="text-xs text-slate-500 mt-1">
Issued: {invoice.date} Due: {invoice.dueDate}
</p>
</div>
<div className="text-right">
<p className="text-3xl font-bold text-[#0A39DF]">${invoice.amount.toLocaleString()}</p>
<div className="flex gap-2 mt-3">
<Button variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" />
Download
</Button>
{invoice.status === "Pending" && (
<Button size="sm" className="bg-[#0A39DF]">
Pay Now
</Button>
)}
</div>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,860 @@
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Tabs, // New import
TabsList, // New import
TabsTrigger, // New import
} from "@/components/ui/tabs"; // New import
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Search, Calendar, MapPin, Users, Eye, Edit, X, Trash2, FileText, // Edit instead of Edit2
Clock, DollarSign, Package, CheckCircle, AlertTriangle, Grid, List, Zap, Plus, Building2, Bell, Edit3, Filter, CalendarIcon, Check, ChevronsUpDown
} from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { format, parseISO, isValid } from "date-fns";
import OrderDetailModal from "../components/orders/OrderDetailModal";
const safeParseDate = (dateString) => {
if (!dateString) return null;
try {
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
return isValid(date) ? date : null;
} catch { return null; }
};
const safeFormatDate = (dateString, formatString) => {
const date = safeParseDate(dateString);
return date ? format(date, formatString) : '—';
};
const convertTo12Hour = (time24) => {
if (!time24) return "-";
try {
const [hours, minutes] = time24.split(':');
const hour = parseInt(hours);
const ampm = hour >= 12 ? 'PM' : 'AM';
const hour12 = hour % 12 || 12;
return `${hour12}:${minutes} ${ampm}`;
} catch {
return time24;
}
};
const getStatusBadge = (event) => {
if (event.is_rapid) {
return (
<div className="inline-flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md">
<Zap className="w-3.5 h-3.5 fill-white" />
RAPID
</div>
);
}
const statusConfig = {
'Draft': { bg: 'bg-slate-500', icon: FileText },
'Pending': { bg: 'bg-amber-500', icon: Clock },
'Partial Staffed': { bg: 'bg-orange-500', icon: AlertTriangle },
'Fully Staffed': { bg: 'bg-emerald-500', icon: CheckCircle },
'Active': { bg: 'bg-blue-500', icon: Users },
'Completed': { bg: 'bg-slate-400', icon: CheckCircle },
'Canceled': { bg: 'bg-red-500', icon: X },
};
const config = statusConfig[event.status] || { bg: 'bg-slate-400', icon: Clock };
const Icon = config.icon;
return (
<div className={`inline-flex items-center gap-2 ${config.bg} text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md`}>
<Icon className="w-3.5 h-3.5" />
{event.status}
</div>
);
};
export default function ClientOrders() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all"); // Updated values for Tabs
const [dateFilter, setDateFilter] = useState("all");
const [specificDate, setSpecificDate] = useState(null);
const [tempDate, setTempDate] = useState(null);
const [locationFilter, setLocationFilter] = useState("all");
const [managerFilter, setManagerFilter] = useState("all");
const [locationOpen, setLocationOpen] = useState(false);
const [managerOpen, setManagerOpen] = useState(false);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false); // Changed from cancelDialog.open
const [orderToCancel, setOrderToCancel] = useState(null); // Changed from cancelDialog.order
const [viewOrderModal, setViewOrderModal] = useState(false);
const [selectedOrder, setSelectedOrder] = useState(null);
const [calendarOpen, setCalendarOpen] = useState(false);
const { data: user } = useQuery({
queryKey: ['current-user-client-orders'],
queryFn: () => base44.auth.me(),
});
const { data: allEvents = [] } = useQuery({
queryKey: ['all-events-client'],
queryFn: () => base44.entities.Event.list('-date'),
});
const clientEvents = useMemo(() => {
return allEvents.filter(e =>
e.client_email === user?.email ||
e.business_name === user?.company_name ||
e.created_by === user?.email
);
}, [allEvents, user]);
const cancelOrderMutation = useMutation({
mutationFn: (orderId) => base44.entities.Event.update(orderId, { status: "Canceled" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['all-events-client'] });
toast({
title: "✅ Order Canceled",
description: "Your order has been canceled successfully",
});
setCancelDialogOpen(false); // Updated
setOrderToCancel(null); // Updated
},
onError: () => {
toast({
title: "❌ Failed to Cancel",
description: "Could not cancel order. Please try again.",
variant: "destructive",
});
},
});
// Get unique locations and managers for filters
const uniqueLocations = useMemo(() => {
const locations = new Set();
clientEvents.forEach(e => {
if (e.hub) locations.add(e.hub);
if (e.event_location) locations.add(e.event_location);
});
return Array.from(locations).sort();
}, [clientEvents]);
const uniqueManagers = useMemo(() => {
const managers = new Set();
clientEvents.forEach(e => {
if (e.manager_name) managers.add(e.manager_name);
// Also check in shifts for manager names
e.shifts?.forEach(shift => {
if (shift.manager_name) managers.add(shift.manager_name);
});
});
return Array.from(managers).sort();
}, [clientEvents]);
const filteredOrders = useMemo(() => { // Renamed from filteredEvents
let filtered = clientEvents;
if (searchTerm) {
const lower = searchTerm.toLowerCase();
filtered = filtered.filter(e =>
e.event_name?.toLowerCase().includes(lower) ||
e.business_name?.toLowerCase().includes(lower) ||
e.hub?.toLowerCase().includes(lower) ||
e.event_location?.toLowerCase().includes(lower) // Added event_location to search
);
}
const now = new Date();
// Reset time for comparison to only compare dates
now.setHours(0, 0, 0, 0);
filtered = filtered.filter(e => {
const eventDate = safeParseDate(e.date);
const isCompleted = e.status === "Completed";
const isCanceled = e.status === "Canceled";
const isFutureOrPresent = eventDate && eventDate >= now;
if (statusFilter === "active") {
return !isCompleted && !isCanceled && isFutureOrPresent;
} else if (statusFilter === "completed") {
return isCompleted;
}
return true; // For "all" or other statuses
});
// Specific date filter (from calendar)
if (specificDate) {
filtered = filtered.filter(e => {
const eventDate = safeParseDate(e.date);
if (!eventDate) return false;
const selectedDateNormalized = new Date(specificDate);
selectedDateNormalized.setHours(0, 0, 0, 0);
eventDate.setHours(0, 0, 0, 0);
return eventDate.getTime() === selectedDateNormalized.getTime();
});
}
// Date range filter
else if (dateFilter !== "all") {
filtered = filtered.filter(e => {
const eventDate = safeParseDate(e.date);
if (!eventDate) return false;
const now = new Date();
now.setHours(0, 0, 0, 0);
if (dateFilter === "today") {
return eventDate.toDateString() === now.toDateString();
} else if (dateFilter === "week") {
const weekFromNow = new Date(now);
weekFromNow.setDate(now.getDate() + 7);
return eventDate >= now && eventDate <= weekFromNow;
} else if (dateFilter === "month") {
const monthFromNow = new Date(now);
monthFromNow.setMonth(now.getMonth() + 1);
return eventDate >= now && eventDate <= monthFromNow;
} else if (dateFilter === "past") {
return eventDate < now;
}
return true;
});
}
// Location filter
if (locationFilter !== "all") {
filtered = filtered.filter(e =>
e.hub === locationFilter || e.event_location === locationFilter
);
}
// Manager filter
if (managerFilter !== "all") {
filtered = filtered.filter(e => {
if (e.manager_name === managerFilter) return true;
// Check shifts for manager
return e.shifts?.some(shift => shift.manager_name === managerFilter);
});
}
return filtered;
}, [clientEvents, searchTerm, statusFilter, dateFilter, specificDate, locationFilter, managerFilter]);
const activeOrders = clientEvents.filter(e =>
e.status !== "Completed" && e.status !== "Canceled"
).length;
const completedOrders = clientEvents.filter(e => e.status === "Completed").length;
const totalSpent = clientEvents
.filter(e => e.status === "Completed")
.reduce((sum, e) => sum + (e.total || 0), 0);
const handleCancelOrder = (order) => {
setOrderToCancel(order); // Updated
setCancelDialogOpen(true); // Updated
};
const handleViewOrder = (order) => {
setSelectedOrder(order);
setViewOrderModal(true);
};
const confirmCancel = () => {
if (orderToCancel) { // Updated
cancelOrderMutation.mutate(orderToCancel.id); // Updated
}
};
const canEditOrder = (order) => {
const eventDate = safeParseDate(order.date);
const now = new Date();
return order.status !== "Completed" &&
order.status !== "Canceled" &&
eventDate && eventDate > now; // Ensure eventDate is valid before comparison
};
const canCancelOrder = (order) => {
return order.status !== "Completed" && order.status !== "Canceled";
};
const getAssignmentStatus = (event) => {
const totalRequested = event.shifts?.reduce((accShift, shift) => {
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
}, 0) || 0;
const assigned = event.assigned_staff?.length || 0;
const percentage = totalRequested > 0 ? Math.round((assigned / totalRequested) * 100) : 0;
let badgeClass = 'bg-slate-100 text-slate-600'; // Default: no staff, or no roles requested
if (assigned > 0 && assigned < totalRequested) {
badgeClass = 'bg-orange-500 text-white'; // Partial Staffed
} else if (assigned >= totalRequested && totalRequested > 0) {
badgeClass = 'bg-emerald-500 text-white'; // Fully Staffed
} else if (assigned === 0 && totalRequested > 0) {
badgeClass = 'bg-red-500 text-white'; // Requested but 0 assigned
} else if (assigned > 0 && totalRequested === 0) {
badgeClass = 'bg-blue-500 text-white'; // Staff assigned but no roles explicitly requested (e.g., event set up, staff assigned, but roles not detailed or count is 0)
}
return {
badgeClass,
assigned,
requested: totalRequested,
percentage,
};
};
const getEventTimes = (event) => {
const firstShift = event.shifts?.[0];
const rolesInFirstShift = firstShift?.roles || [];
let startTime = null;
let endTime = null;
if (rolesInFirstShift.length > 0) {
startTime = rolesInFirstShift[0].start_time || null;
endTime = rolesInFirstShift[0].end_time || null;
}
return {
startTime: startTime ? convertTo12Hour(startTime) : "-",
endTime: endTime ? convertTo12Hour(endTime) : "-"
};
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-[1800px] mx-auto space-y-6">
<div className=""> {/* Removed mb-6 */}
<h1 className="text-2xl font-bold text-slate-900">My Orders</h1>
<p className="text-sm text-slate-500 mt-1">View and manage all your orders</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> {/* Removed mb-6 from here as it's now part of space-y-6 */}
<Card className="border border-blue-200 bg-blue-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
<Package className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-blue-600 font-semibold uppercase">TOTAL</p>
<p className="text-2xl font-bold text-blue-700">{clientEvents.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-orange-200 bg-orange-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center">
<Clock className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-orange-600 font-semibold uppercase">ACTIVE</p>
<p className="text-2xl font-bold text-orange-700">{activeOrders}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-green-200 bg-green-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center">
<CheckCircle className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-green-600 font-semibold uppercase">COMPLETED</p>
<p className="text-2xl font-bold text-green-700">{completedOrders}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-purple-200 bg-purple-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-500 rounded-lg flex items-center justify-center">
<DollarSign className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-purple-600 font-semibold uppercase">TOTAL SPENT</p>
<p className="text-2xl font-bold text-purple-700">${Math.round(totalSpent / 1000)}k</p>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="bg-white rounded-xl p-4 border shadow-sm">
<div className="flex items-center gap-4 mb-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 orders..."
value={searchTerm}
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>
<Card className="border-slate-200 shadow-sm"> {/* Card class updated */}
<CardContent className="p-0"> {/* CardContent padding updated */}
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50">
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Business</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Hub</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Event</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Date & Time</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Status</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Requested</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Assigned</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Invoice</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredOrders.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-12 text-slate-500">
<Package className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p className="font-medium">No orders found</p>
</TableCell>
</TableRow>
) : (
filteredOrders.map((order) => {
const assignedCount = order.assigned_staff?.length || 0;
const requestedCount = order.requested || 0;
const assignmentProgress = requestedCount > 0 ? Math.round((assignedCount / requestedCount) * 100) : 0;
const { startTime, endTime } = getEventTimes(order);
return (
<TableRow key={order.id} className="hover:bg-slate-50">
<TableCell>
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-blue-600" />
<span className="text-sm font-medium text-slate-900">{order.business_name || "Primary Location"}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-purple-600" />
<span className="text-sm text-slate-700">{order.hub || "Main Hub"}</span>
</div>
</TableCell>
<TableCell>
<p className="font-semibold text-slate-900">{order.event_name || "Untitled Event"}</p>
</TableCell>
<TableCell>
<div>
<p className="font-medium text-slate-900">{safeFormatDate(order.date, 'MM.dd.yyyy')}</p>
<p className="text-xs text-slate-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{startTime} - {endTime}
</p>
</div>
</TableCell>
<TableCell>
{getStatusBadge(order)}
</TableCell>
<TableCell>
<span className="text-lg font-bold text-slate-900">{requestedCount}</span>
</TableCell>
<TableCell>
<div className="flex flex-col items-center gap-1">
<div className="w-10 h-10 bg-emerald-500 rounded-full flex items-center justify-center">
<span className="text-white font-bold text-sm">{assignedCount}</span>
</div>
<span className="text-xs text-emerald-600 font-semibold">{assignmentProgress}%</span>
</div>
</TableCell>
<TableCell>
<button className="w-8 h-8 flex items-center justify-center hover:bg-slate-100 rounded transition-colors">
<FileText className="w-5 h-5 text-slate-400" />
</button>
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleViewOrder(order)}
className="h-8 w-8 p-0"
title="View"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 p-0"
title="Notifications"
>
<Bell className="w-4 h-4" />
</Button>
{canEditOrder(order) && (
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EditEvent?id=${order.id}`))}
className="h-8 w-8 p-0"
title="Edit"
>
<Edit3 className="w-4 h-4" />
</Button>
)}
{canCancelOrder(order) && (
<Button
variant="ghost"
size="icon"
onClick={() => handleCancelOrder(order)}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
title="Cancel"
>
<X className="w-4 h-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<OrderDetailModal
open={viewOrderModal}
onClose={() => setViewOrderModal(false)}
order={selectedOrder}
onCancel={handleCancelOrder}
/>
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}> {/* Updated open and onOpenChange */}
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="w-5 h-5" />
Cancel Order?
</DialogTitle>
<DialogDescription>
Are you sure you want to cancel this order? This action cannot be undone.
</DialogDescription>
</DialogHeader>
{orderToCancel && ( // Using orderToCancel
<div className="bg-slate-50 rounded-lg p-4 space-y-2">
<p className="font-bold text-slate-900">{orderToCancel.event_name}</p>
<div className="flex items-center gap-2 text-sm text-slate-600">
<Calendar className="w-4 h-4" />
{orderToCancel.date ? format(new Date(orderToCancel.date), "MMMM d, yyyy") : "—"}
</div>
<div className="flex items-center gap-2 text-sm text-slate-600">
<MapPin className="w-4 h-4" />
{orderToCancel.hub || orderToCancel.event_location}
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setCancelDialogOpen(false)} // Updated
>
Keep Order
</Button>
<Button
variant="destructive"
onClick={confirmCancel}
disabled={cancelOrderMutation.isPending}
>
{cancelOrderMutation.isPending ? "Canceling..." : "Yes, Cancel Order"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,191 @@
import React from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import EventFormWizard from "../components/events/EventFormWizard";
import RapidOrderInterface from "../components/orders/RapidOrderInterface";
import { useToast } from "@/components/ui/use-toast";
import { Button } from "@/components/ui/button";
import { X, AlertTriangle } from "lucide-react";
import { detectAllConflicts, ConflictAlert } from "../components/scheduling/ConflictDetection";
import { Card, CardContent } from "@/components/ui/card";
export default function CreateEvent() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const [pendingEvent, setPendingEvent] = React.useState(null);
const [showConflictWarning, setShowConflictWarning] = React.useState(false);
const [showRapidInterface, setShowRapidInterface] = React.useState(false);
const { data: currentUser } = useQuery({
queryKey: ['current-user-create-event'],
queryFn: () => base44.auth.me(),
});
const { data: allEvents = [] } = useQuery({
queryKey: ['events-for-conflict-check'],
queryFn: () => base44.entities.Event.list(),
initialData: [],
});
const createEventMutation = useMutation({
mutationFn: (eventData) => base44.entities.Event.create(eventData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: ['client-events'] });
toast({
title: "✅ Event Created",
description: "Your event has been created successfully.",
});
navigate(createPageUrl("Events"));
},
onError: (error) => {
toast({
title: "❌ Failed to Create Event",
description: error.message || "There was an error creating the event.",
variant: "destructive",
});
},
});
const handleRapidSubmit = (rapidData) => {
// Convert rapid order message to event data
const eventData = {
event_name: "RAPID Order",
order_type: "rapid",
date: new Date().toISOString().split('T')[0],
status: "Active",
notes: rapidData.rawMessage,
shifts: [{
shift_name: "Shift 1",
location_address: "",
same_as_billing: true,
roles: [{
role: "",
department: "",
count: 1,
start_time: "09:00",
end_time: "17:00",
hours: 8,
uniform: "Type 1",
break_minutes: 15,
rate_per_hour: 0,
total_value: 0
}]
}],
requested: 1
};
createEventMutation.mutate(eventData);
};
const handleSubmit = (eventData) => {
// CRITICAL: Calculate total requested count from all roles before creating
const totalRequested = eventData.shifts.reduce((sum, shift) => {
return sum + shift.roles.reduce((roleSum, role) => roleSum + (parseInt(role.count) || 0), 0);
}, 0);
const eventDataWithRequested = {
...eventData,
requested: totalRequested // Set exact requested count
};
// Detect conflicts before creating
const conflicts = detectAllConflicts(eventDataWithRequested, allEvents);
if (conflicts.length > 0) {
setPendingEvent({ ...eventDataWithRequested, detected_conflicts: conflicts });
setShowConflictWarning(true);
} else {
createEventMutation.mutate(eventDataWithRequested);
}
};
const handleConfirmWithConflicts = () => {
if (pendingEvent) {
createEventMutation.mutate(pendingEvent);
setShowConflictWarning(false);
setPendingEvent(null);
}
};
const handleCancelConflicts = () => {
setShowConflictWarning(false);
setPendingEvent(null);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="max-w-7xl mx-auto p-4 md:p-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-[#1C323E]">Create Standard Order</h1>
<p className="text-slate-600 mt-1">
Fill out the details for your planned event
</p>
</div>
<Button
variant="ghost"
onClick={() => navigate(createPageUrl("ClientDashboard"))}
>
<X className="w-4 h-4" />
</Button>
</div>
{/* Conflict Warning Modal */}
{showConflictWarning && pendingEvent && (
<Card className="mb-6 border-2 border-orange-500">
<CardContent className="p-6">
<div className="flex items-start gap-4 mb-4">
<div className="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-6 h-6 text-orange-600" />
</div>
<div>
<h3 className="font-bold text-lg text-slate-900 mb-1">
Scheduling Conflicts Detected
</h3>
<p className="text-sm text-slate-600">
This event has {pendingEvent.detected_conflicts.length} potential conflict{pendingEvent.detected_conflicts.length !== 1 ? 's' : ''}
with existing bookings. Review the conflicts below and decide how to proceed.
</p>
</div>
</div>
<div className="mb-6">
<ConflictAlert conflicts={pendingEvent.detected_conflicts} />
</div>
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={handleCancelConflicts}
>
Go Back & Edit
</Button>
<Button
onClick={handleConfirmWithConflicts}
className="bg-orange-600 hover:bg-orange-700"
>
Create Anyway
</Button>
</div>
</CardContent>
</Card>
)}
<EventFormWizard
event={null}
onSubmit={handleSubmit}
onRapidSubmit={handleRapidSubmit}
isSubmitting={createEventMutation.isPending}
currentUser={currentUser}
onCancel={() => navigate(createPageUrl("ClientDashboard"))}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,258 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ArrowLeft, Save, Upload, Users } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
export default function CreateTeam() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const { data: user } = useQuery({
queryKey: ['current-user-create-team'],
queryFn: () => base44.auth.me(),
});
const [formData, setFormData] = useState({
team_name: "",
full_name: user?.full_name || "",
email: user?.email || "",
phone: user?.phone || "",
address: user?.address || "",
city: user?.city || "",
zip_code: user?.zip_code || "",
company_logo: "",
});
const [logoFile, setLogoFile] = useState(null);
const [uploadingLogo, setUploadingLogo] = useState(false);
const createTeamMutation = useMutation({
mutationFn: (teamData) => base44.entities.Team.create(teamData),
onSuccess: (newTeam) => {
queryClient.invalidateQueries({ queryKey: ['teams'] });
toast({
title: "Team Created",
description: "Your team has been created successfully",
});
navigate(createPageUrl(`TeamDetails?id=${newTeam.id}`));
},
});
const handleLogoUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingLogo(true);
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
setFormData({ ...formData, company_logo: file_url });
setLogoFile(file);
toast({
title: "Logo Uploaded",
description: "Company logo uploaded successfully",
});
} catch (error) {
toast({
title: "Upload Failed",
description: "Failed to upload logo",
variant: "destructive"
});
} finally {
setUploadingLogo(false);
}
};
const handleSubmit = (e) => {
e.preventDefault();
const teamData = {
...formData,
owner_id: user?.id,
owner_name: user?.full_name || user?.email,
owner_role: user?.user_role || user?.role,
total_members: 0,
active_members: 0,
total_hubs: 0,
favorite_staff_count: 0,
blocked_staff_count: 0,
};
createTeamMutation.mutate(teamData);
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-4xl mx-auto">
<Button
variant="ghost"
onClick={() => navigate(createPageUrl("Teams"))}
className="mb-4 hover:bg-slate-100"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Teams
</Button>
<div className="mb-8">
<h1 className="text-3xl font-bold text-[#1C323E] mb-2">Create New Team</h1>
<p className="text-slate-600">Set up your team to manage members and operations</p>
</div>
<form onSubmit={handleSubmit}>
<Card className="border-slate-200 shadow-lg mb-6">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle className="flex items-center gap-2">
<Users className="w-5 h-5 text-[#0A39DF]" />
Team Information
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
{/* Company Logo */}
<div>
<Label className="text-sm font-semibold text-slate-700 mb-2 block">Company Logo</Label>
<div className="flex items-center gap-4">
{formData.company_logo ? (
<img src={formData.company_logo} alt="Company Logo" className="w-24 h-24 rounded-xl object-cover border-2 border-slate-200" />
) : (
<div className="w-24 h-24 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center text-white text-3xl font-bold">
{formData.team_name?.charAt(0) || 'T'}
</div>
)}
<div>
<input
type="file"
id="logo"
accept="image/*"
onChange={handleLogoUpload}
className="hidden"
/>
<Button
type="button"
variant="outline"
onClick={() => document.getElementById('logo').click()}
disabled={uploadingLogo}
>
<Upload className="w-4 h-4 mr-2" />
{uploadingLogo ? 'Uploading...' : 'Upload Logo'}
</Button>
<p className="text-xs text-slate-500 mt-2">PNG, JPG up to 10MB</p>
</div>
</div>
</div>
{/* Team Name */}
<div>
<Label htmlFor="team_name" className="text-sm font-semibold text-slate-700">Team Name *</Label>
<Input
id="team_name"
value={formData.team_name}
onChange={(e) => setFormData({ ...formData, team_name: e.target.value })}
placeholder="e.g., Legendary Event Staffing"
required
className="mt-1"
/>
</div>
{/* Personal Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="full_name" className="text-sm font-semibold text-slate-700">Full Name</Label>
<Input
id="full_name"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
placeholder="Jon Holt"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="email" className="text-sm font-semibold text-slate-700">Email</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="JonHolt@LegendaryEXE.com"
className="mt-1"
/>
</div>
</div>
<div>
<Label htmlFor="phone" className="text-sm font-semibold text-slate-700">Phone</Label>
<Input
id="phone"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
placeholder="+55 (41) 9797-7777"
className="mt-1"
/>
</div>
{/* Contact Info */}
<div>
<Label htmlFor="address" className="text-sm font-semibold text-slate-700">Address</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="276 Redwood Shores, Redwood City, CA 94065, USA"
className="mt-1"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="city" className="text-sm font-semibold text-slate-700">City</Label>
<Input
id="city"
value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
placeholder="Redwood City"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="zip_code" className="text-sm font-semibold text-slate-700">ZIP Code</Label>
<Input
id="zip_code"
value={formData.zip_code}
onChange={(e) => setFormData({ ...formData, zip_code: e.target.value })}
placeholder="94065"
className="mt-1"
/>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate(createPageUrl("Teams"))}
>
Cancel
</Button>
<Button
type="submit"
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
disabled={createTeamMutation.isPending}
>
<Save className="w-4 h-4 mr-2" />
{createTeamMutation.isPending ? 'Creating...' : 'Create Team'}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,488 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Users, Building2, UserPlus, TrendingUp, MapPin, Calendar, DollarSign, Award, Target, BarChart3, Shield, Leaf, Eye, Edit, Sparkles, Zap, Clock, AlertTriangle, CheckCircle, FileText, X } from "lucide-react";
import StatsCard from "../components/staff/StatsCard";
import EcosystemWheel from "../components/dashboard/EcosystemWheel";
import QuickMetrics from "../components/dashboard/QuickMetrics";
import PageHeader from "../components/common/PageHeader";
import { format, parseISO, isValid, isSameDay, startOfDay } from "date-fns";
const safeParseDate = (dateString) => {
if (!dateString) return null;
try {
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
return isValid(date) ? date : null;
} catch { return null; }
};
const safeFormatDate = (dateString, formatStr) => {
const date = safeParseDate(dateString);
if (!date) return "-";
try { return format(date, formatStr); } catch { return "-"; }
};
const convertTo12Hour = (time24) => {
if (!time24) return "-";
try {
const [hours, minutes] = time24.split(':');
const hour = parseInt(hours);
const ampm = hour >= 12 ? 'PM' : 'AM';
const hour12 = hour % 12 || 12;
return `${hour12}:${minutes} ${ampm}`;
} catch {
return time24;
}
};
const getStatusBadge = (event) => {
if (event.is_rapid) {
return (
<div className="inline-flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md">
<Zap className="w-3.5 h-3.5 fill-white" />
RAPID
</div>
);
}
const statusConfig = {
'Draft': { bg: 'bg-slate-500', icon: FileText },
'Pending': { bg: 'bg-amber-500', icon: Clock },
'Partial Staffed': { bg: 'bg-orange-500', icon: AlertTriangle },
'Fully Staffed': { bg: 'bg-emerald-500', icon: CheckCircle },
'Active': { bg: 'bg-blue-500', icon: Users },
'Completed': { bg: 'bg-slate-400', icon: CheckCircle },
'Canceled': { bg: 'bg-red-500', icon: X },
};
const config = statusConfig[event.status] || { bg: 'bg-slate-400', icon: Clock };
const Icon = config.icon;
return (
<div className={`inline-flex items-center gap-2 ${config.bg} text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md`}>
<Icon className="w-3.5 h-3.5" />
{event.status}
</div>
);
};
const getEventTimes = (event) => {
const firstShift = event.shifts?.[0];
const rolesInFirstShift = firstShift?.roles || [];
let startTime = null;
let endTime = null;
if (rolesInFirstShift.length > 0) {
startTime = rolesInFirstShift[0].start_time || null;
endTime = rolesInFirstShift[0].end_time || null;
}
return {
startTime: startTime ? convertTo12Hour(startTime) : "-",
endTime: endTime ? convertTo12Hour(endTime) : "-"
};
};
const getAssignmentStatus = (event) => {
const totalRequested = event.shifts?.reduce((accShift, shift) => {
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
}, 0) || 0;
const assigned = event.assigned_staff?.length || 0;
const fillPercent = totalRequested > 0 ? Math.round((assigned / totalRequested) * 100) : 0;
if (assigned === 0) return { color: 'bg-slate-200 text-slate-600', text: '0', percent: '0%', status: 'empty' };
if (totalRequested > 0 && assigned >= totalRequested) return { color: 'bg-emerald-500 text-white', text: assigned, percent: '100%', status: 'full' };
if (totalRequested > 0 && assigned < totalRequested) return { color: 'bg-slate-200 text-slate-600', text: assigned, percent: `${fillPercent}%`, status: 'partial' };
return { color: 'bg-slate-200 text-slate-600', text: assigned, percent: '0%', status: 'partial' };
};
export default function Dashboard() {
const navigate = useNavigate();
const [selectedLayer, setSelectedLayer] = useState(null);
const { data: staff, isLoading: loadingStaff } = useQuery({
queryKey: ['staff'],
queryFn: () => base44.entities.Staff.list('-created_date'),
initialData: [],
});
const { data: events, isLoading: loadingEvents } = useQuery({
queryKey: ['events'],
queryFn: () => base44.entities.Event.list('-date'),
initialData: [],
});
// Filter events for today only
const today = startOfDay(new Date());
const todaysEvents = events.filter(event => {
const eventDate = safeParseDate(event.date);
return eventDate && isSameDay(eventDate, today);
});
const recentStaff = staff.slice(0, 6);
const uniqueDepartments = [...new Set(staff.map(s => s.department).filter(Boolean))];
const uniqueLocations = [...new Set(staff.map(s => s.hub_location).filter(Boolean))];
// Calculate key metrics
const totalFillRate = 97;
const totalSpend = 2.3;
const overallScore = "A+";
const activeEvents = events.filter(e => e.status === "Active" || e.status === "Confirmed").length;
const completionRate = events.length > 0 ? Math.round((events.filter(e => e.status === "Completed").length / events.length) * 100) : 0;
const ecosystemLayers = [
{
name: "Buyer(Procurements)",
icon: DollarSign,
color: "from-[#0A39DF] to-[#1C323E]",
metrics: { fillRate: "97%", spend: "$2.3M", score: "A+" },
route: "ProcurementDashboard"
},
{
name: "Enterprises (Operator)",
icon: Target,
color: "from-emerald-500 to-emerald-700",
metrics: { coverage: "94%", incidents: "2", satisfaction: "4.8/5" },
route: "OperatorDashboard"
},
{
name: "Sectors (Execution)",
icon: Building2,
color: "from-purple-500 to-purple-700",
metrics: { active: activeEvents, revenue: "$1.8M", growth: "+12%" },
route: "OperatorDashboard"
},
{
name: "Partner",
icon: Users,
color: "from-pink-500 to-pink-700",
metrics: { total: "45", retention: "92%", nps: "8.5" },
route: "Business"
},
{
name: "Approved Vendor",
icon: Award,
color: "from-amber-500 to-amber-700",
metrics: { partners: "12", rating: "4.7/5", compliance: "98%" },
route: "VendorDashboard"
},
{
name: "Workforce",
icon: UserPlus,
color: "from-[#0A39DF] to-[#0A39DF]/80",
metrics: { total: staff.length, active: staff.filter(s => s.check_in).length, trained: "89%" },
route: "WorkforceDashboard"
}
];
return (
<div className="p-4 md:p-8 bg-gradient-to-br from-slate-50 to-slate-100 min-h-screen">
<div className="max-w-7xl mx-auto">
<PageHeader
title="Welcome to KROW"
subtitle="Your Complete Workforce Management Ecosystem"
actions={
<>
<Button variant="outline" className="border-slate-300 hover:bg-slate-100">
<BarChart3 className="w-4 h-4 mr-2" />
Reports
</Button>
<Link to={createPageUrl("Events")}>
<Button className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg">
<Calendar className="w-5 h-5 mr-2" />
View All Orders
</Button>
</Link>
</>
}
/>
{/* Global Metrics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<StatsCard
title="Fill Rate"
value={`${totalFillRate}%`}
icon={Target}
gradient="bg-gradient-to-br from-[#0A39DF] to-[#1C323E]"
change="+2.5% this month"
/>
<StatsCard
title="Total Spend"
value={`$${totalSpend}M`}
icon={DollarSign}
gradient="bg-gradient-to-br from-emerald-500 to-emerald-700"
change="+$180K this month"
/>
<StatsCard
title="Overall Score"
value={overallScore}
icon={Award}
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
/>
<StatsCard
title="Active Events"
value={activeEvents}
icon={Calendar}
gradient="bg-gradient-to-br from-purple-500 to-purple-700"
change={`${completionRate}% completion rate`}
/>
</div>
{/* Today's Orders Section */}
<Card className="mb-8 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Calendar className="w-6 h-6 text-[#0A39DF]" />
Today's Orders - {format(today, 'EEEE, MMMM d, yyyy')}
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Orders scheduled for today only</p>
</div>
<Link to={createPageUrl("Events")}>
<Button variant="outline" className="border-slate-300">
View All Orders
</Button>
</Link>
</div>
</CardHeader>
<CardContent className="p-0">
{todaysEvents.length === 0 ? (
<div className="text-center py-12 text-slate-500">
<Calendar className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p className="font-medium">No orders scheduled for today</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50 border-b">
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide h-10">BUSINESS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">HUB</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">EVENT</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">DATE & TIME</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">STATUS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">REQUESTED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ASSIGNED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{todaysEvents.map((event) => {
const assignmentStatus = getAssignmentStatus(event);
const eventTimes = getEventTimes(event);
const eventDate = safeParseDate(event.date);
const dayOfWeek = eventDate ? format(eventDate, 'EEEE') : '';
return (
<TableRow key={event.id} className="hover:bg-slate-50 transition-colors border-b">
<TableCell className="py-3">
<p className="text-sm text-slate-700 font-medium">{event.business_name || "—"}</p>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center gap-1.5 text-sm text-slate-500">
<MapPin className="w-3.5 h-3.5" />
{event.hub || event.event_location || "Main Hub"}
</div>
</TableCell>
<TableCell className="py-3">
<p className="font-semibold text-slate-900 text-sm">{event.event_name}</p>
</TableCell>
<TableCell className="py-3">
<div className="space-y-0.5">
<p className="text-sm text-slate-900 font-semibold">{eventDate ? format(eventDate, 'MM.dd.yyyy') : '-'}</p>
<p className="text-xs text-slate-500">{dayOfWeek}</p>
<div className="flex items-center gap-1 text-xs text-slate-600 mt-1">
<Clock className="w-3 h-3" />
<span>{eventTimes.startTime} - {eventTimes.endTime}</span>
</div>
</div>
</TableCell>
<TableCell className="py-3">
{getStatusBadge(event)}
</TableCell>
<TableCell className="text-center py-3">
<span className="font-semibold text-slate-700 text-sm">{event.requested || 0}</span>
</TableCell>
<TableCell className="text-center py-3">
<div className="flex flex-col items-center gap-1">
<div className={`w-10 h-10 rounded-full ${assignmentStatus.color} flex items-center justify-center font-bold text-sm`}>
{assignmentStatus.text}
</div>
<span className="text-[10px] text-slate-500 font-medium">{assignmentStatus.percent}</span>
</div>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EventDetail?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="View"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="Edit"
>
<Edit className="w-4 h-4" />
</Button>
{event.invoice_id && (
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`Invoices?id=${event.invoice_id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="View Invoice"
>
<FileText className="w-4 h-4 text-blue-600" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Ecosystem Puzzle */}
<Card className="mb-8 border-slate-200 shadow-xl overflow-hidden">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Target className="w-6 h-6 text-[#0A39DF]" />
Ecosystem Connection Map
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Interactive puzzle showing how each layer connects Hover to see metrics Click to explore</p>
</CardHeader>
<CardContent className="p-8">
<EcosystemWheel
layers={ecosystemLayers}
onLayerClick={(layer) => navigate(createPageUrl(layer.route))}
selectedLayer={selectedLayer}
onLayerHover={setSelectedLayer}
/>
</CardContent>
</Card>
{/* Quick Access Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<QuickMetrics
title="Procurement & Vendor Intelligence"
description="Vendor efficiency, spend analysis, compliance tracking"
icon={Shield}
metrics={[
{ label: "Vendor Score", value: "A+", color: "text-green-600" },
{ label: "Compliance", value: "98%", color: "text-blue-600" },
{ label: "ESG Rating", value: "B+", color: "text-emerald-600" }
]}
route="ProcurementDashboard"
gradient="from-[#0A39DF]/10 to-[#1C323E]/10"
/>
<QuickMetrics
title="Operator & Sector Dashboard"
description="Live coverage, demand forecast, incident tracking"
icon={MapPin}
metrics={[
{ label: "Coverage", value: "94%", color: "text-green-600" },
{ label: "Incidents", value: "2", color: "text-yellow-600" },
{ label: "Forecast Accuracy", value: "91%", color: "text-blue-600" }
]}
route="OperatorDashboard"
gradient="from-emerald-500/10 to-emerald-700/10"
/>
<QuickMetrics
title="Vendor Dashboard"
description="Orders, invoices, workforce pulse, KROW score"
icon={Award}
metrics={[
{ label: "Fill Rate", value: "97%", color: "text-green-600" },
{ label: "Attendance", value: "95%", color: "text-blue-600" },
{ label: "Training", value: "92%", color: "text-purple-600" }
]}
route="VendorDashboard"
gradient="from-amber-500/10 to-amber-700/10"
/>
</div>
{/* Workforce Section */}
<Card className="border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Users className="w-6 h-6 text-[#0A39DF]" />
Workforce Overview
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Recent additions and active workers</p>
</div>
<div className="flex gap-2">
<Link to={createPageUrl("WorkforceDashboard")}>
<Button variant="outline" className="border-slate-300">
View Workforce App
</Button>
</Link>
<Link to={createPageUrl("StaffDirectory")}>
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
View All Staff
</Button>
</Link>
</div>
</div>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{recentStaff.slice(0, 3).map((member) => (
<div key={member.id} className="p-4 rounded-lg border border-slate-200 hover:border-[#0A39DF] hover:shadow-md transition-all">
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center text-white font-bold">
{member.initial || member.employee_name?.charAt(0)}
</div>
<div>
<h4 className="font-semibold text-[#1C323E]">{member.employee_name}</h4>
<p className="text-sm text-slate-500">{member.position}</p>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-500">Rating:</span>
<span className="font-semibold">{member.rating || 0}/5 </span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Coverage:</span>
<span className="font-semibold text-green-600">{member.shift_coverage_percentage || 0}%</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Cancellations:</span>
<span className="font-semibold text-red-600">{member.cancellation_count || 0}</span>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,321 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Building2, ArrowLeft, Save, Loader2 } from "lucide-react";
import PageHeader from "../components/common/PageHeader";
import { useToast } from "@/components/ui/use-toast";
export default function EditEnterprise() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const urlParams = new URLSearchParams(window.location.search);
const enterpriseId = urlParams.get('id');
const { data: enterprises = [] } = useQuery({
queryKey: ['enterprises'],
queryFn: () => base44.entities.Enterprise.list(),
initialData: [],
});
const enterprise = enterprises.find(e => e.id === enterpriseId);
const [enterpriseData, setEnterpriseData] = useState({
enterprise_number: "",
enterprise_name: "",
enterprise_code: "",
brand_family: [],
headquarters_address: "",
global_policies: {
minimum_wage: 16.50,
overtime_rules: "Time and half after 8 hours",
uniform_standards: "",
background_check_required: true
},
rate_guardrails: {
minimum_markup_percentage: 15,
maximum_markup_percentage: 35,
minimum_bill_rate: 18.00
},
primary_contact_name: "",
primary_contact_email: "",
is_active: true
});
const [brandInput, setBrandInput] = useState("");
React.useEffect(() => {
if (enterprise) {
setEnterpriseData({
enterprise_number: enterprise.enterprise_number || "",
enterprise_name: enterprise.enterprise_name || "",
enterprise_code: enterprise.enterprise_code || "",
brand_family: enterprise.brand_family || [],
headquarters_address: enterprise.headquarters_address || "",
global_policies: enterprise.global_policies || {
minimum_wage: 16.50,
overtime_rules: "Time and half after 8 hours",
uniform_standards: "",
background_check_required: true
},
rate_guardrails: enterprise.rate_guardrails || {
minimum_markup_percentage: 15,
maximum_markup_percentage: 35,
minimum_bill_rate: 18.00
},
primary_contact_name: enterprise.primary_contact_name || "",
primary_contact_email: enterprise.primary_contact_email || "",
is_active: enterprise.is_active !== undefined ? enterprise.is_active : true
});
}
}, [enterprise]);
const updateEnterpriseMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Enterprise.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['enterprises'] });
toast({
title: "Enterprise Updated",
description: "Enterprise has been successfully updated",
});
navigate(createPageUrl("EnterpriseManagement"));
},
});
const handleSubmit = (e) => {
e.preventDefault();
updateEnterpriseMutation.mutate({ id: enterpriseId, data: enterpriseData });
};
const handleAddBrand = () => {
if (brandInput.trim()) {
setEnterpriseData({
...enterpriseData,
brand_family: [...enterpriseData.brand_family, brandInput.trim()]
});
setBrandInput("");
}
};
const handleRemoveBrand = (index) => {
setEnterpriseData({
...enterpriseData,
brand_family: enterpriseData.brand_family.filter((_, i) => i !== index)
});
};
if (!enterprise) {
return (
<div className="p-8 text-center">
<Loader2 className="w-8 h-8 animate-spin text-[#0A39DF] mx-auto mb-4" />
<h2 className="text-2xl font-bold text-slate-900 mb-4">Loading Enterprise...</h2>
</div>
);
}
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-4xl mx-auto">
<PageHeader
title="Edit Enterprise"
subtitle={`Update information for ${enterprise.enterprise_name}`}
backTo={createPageUrl("EnterpriseManagement")}
backButtonLabel="Back to Enterprises"
/>
<form onSubmit={handleSubmit}>
<Card className="mb-6 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle className="flex items-center gap-2">
<Building2 className="w-5 h-5 text-[#0A39DF]" />
Basic Information
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label htmlFor="enterprise_number">Enterprise Number</Label>
<Input
id="enterprise_number"
value={enterpriseData.enterprise_number}
readOnly
className="bg-slate-50 font-mono"
/>
<p className="text-xs text-slate-500 mt-1">Auto-generated unique ID</p>
</div>
<div>
<Label htmlFor="enterprise_code">Enterprise Code *</Label>
<Input
id="enterprise_code"
placeholder="e.g., COMP"
value={enterpriseData.enterprise_code}
onChange={(e) => setEnterpriseData({...enterpriseData, enterprise_code: e.target.value.toUpperCase()})}
required
/>
</div>
<div className="md:col-span-2">
<Label htmlFor="enterprise_name">Enterprise Name *</Label>
<Input
id="enterprise_name"
placeholder="e.g., Compass Group"
value={enterpriseData.enterprise_name}
onChange={(e) => setEnterpriseData({...enterpriseData, enterprise_name: e.target.value})}
required
/>
</div>
<div>
<Label htmlFor="primary_contact_name">Primary Contact Name</Label>
<Input
id="primary_contact_name"
placeholder="John Doe"
value={enterpriseData.primary_contact_name}
onChange={(e) => setEnterpriseData({...enterpriseData, primary_contact_name: e.target.value})}
/>
</div>
<div>
<Label htmlFor="primary_contact_email">Primary Contact Email</Label>
<Input
id="primary_contact_email"
type="email"
placeholder="john@enterprise.com"
value={enterpriseData.primary_contact_email}
onChange={(e) => setEnterpriseData({...enterpriseData, primary_contact_email: e.target.value})}
/>
</div>
</div>
<div>
<Label htmlFor="headquarters_address">Headquarters Address</Label>
<Textarea
id="headquarters_address"
placeholder="123 Main St, City, State ZIP"
value={enterpriseData.headquarters_address}
onChange={(e) => setEnterpriseData({...enterpriseData, headquarters_address: e.target.value})}
rows={2}
/>
</div>
<div>
<Label>Brand Family</Label>
<div className="flex gap-2 mb-2">
<Input
placeholder="Add brand (e.g., Bon Appétit)"
value={brandInput}
onChange={(e) => setBrandInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddBrand();
}
}}
/>
<Button type="button" onClick={handleAddBrand} variant="outline">
Add
</Button>
</div>
<div className="flex flex-wrap gap-2">
{enterpriseData.brand_family.map((brand, index) => (
<div key={index} className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full flex items-center gap-2">
<span>{brand}</span>
<button
type="button"
onClick={() => handleRemoveBrand(index)}
className="text-blue-600 hover:text-blue-800"
>
×
</button>
</div>
))}
</div>
</div>
</CardContent>
</Card>
<Card className="mb-6 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle>Rate Guardrails</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Minimum Markup %</Label>
<Input
type="number"
value={enterpriseData.rate_guardrails.minimum_markup_percentage}
onChange={(e) => setEnterpriseData({
...enterpriseData,
rate_guardrails: {...enterpriseData.rate_guardrails, minimum_markup_percentage: parseFloat(e.target.value)}
})}
/>
</div>
<div>
<Label>Maximum Markup %</Label>
<Input
type="number"
value={enterpriseData.rate_guardrails.maximum_markup_percentage}
onChange={(e) => setEnterpriseData({
...enterpriseData,
rate_guardrails: {...enterpriseData.rate_guardrails, maximum_markup_percentage: parseFloat(e.target.value)}
})}
/>
</div>
<div>
<Label>Minimum Bill Rate</Label>
<Input
type="number"
step="0.01"
value={enterpriseData.rate_guardrails.minimum_bill_rate}
onChange={(e) => setEnterpriseData({
...enterpriseData,
rate_guardrails: {...enterpriseData.rate_guardrails, minimum_bill_rate: parseFloat(e.target.value)}
})}
/>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate(createPageUrl("EnterpriseManagement"))}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
disabled={updateEnterpriseMutation.isPending}
>
{updateEnterpriseMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Updating...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Update Enterprise
</>
)}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,214 @@
import React, { useState, useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Loader2 } from "lucide-react";
import EventFormWizard from "../components/events/EventFormWizard";
import OrderReductionAlert from "../components/orders/OrderReductionAlert";
import { useToast } from "@/components/ui/use-toast";
export default function EditEvent() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const urlParams = new URLSearchParams(window.location.search);
const eventId = urlParams.get('id');
const [showReductionAlert, setShowReductionAlert] = useState(false);
const [pendingUpdate, setPendingUpdate] = useState(null);
const [originalRequested, setOriginalRequested] = useState(0);
const { data: user } = useQuery({
queryKey: ['current-user-edit-event'],
queryFn: () => base44.auth.me(),
});
const { data: allEvents, isLoading } = useQuery({
queryKey: ['events'],
queryFn: () => base44.entities.Event.list(),
initialData: [],
});
const { data: allStaff = [] } = useQuery({
queryKey: ['staff-for-reduction'],
queryFn: () => base44.entities.Staff.list(),
initialData: [],
});
const event = allEvents.find(e => e.id === eventId);
useEffect(() => {
if (event) {
setOriginalRequested(event.requested || 0);
}
}, [event]);
const updateEventMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Event.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
navigate(createPageUrl("Events"));
},
});
const handleSubmit = (eventData) => {
// CRITICAL: Recalculate requested count from current roles
const totalRequested = eventData.shifts.reduce((sum, shift) => {
return sum + shift.roles.reduce((roleSum, role) => roleSum + (parseInt(role.count) || 0), 0);
}, 0);
const assignedCount = event.assigned_staff?.length || 0;
const isVendor = user?.user_role === 'vendor' || user?.role === 'vendor';
// If client is reducing headcount and vendor has already assigned staff
if (!isVendor && totalRequested < originalRequested && assignedCount > totalRequested) {
setPendingUpdate({ ...eventData, requested: totalRequested });
setShowReductionAlert(true);
// Notify vendor via email
if (event.vendor_name) {
base44.integrations.Core.SendEmail({
to: `${event.vendor_name}@example.com`,
subject: `⚠️ Order Reduced: ${event.event_name}`,
body: `Client has reduced headcount for order: ${event.event_name}\n\nOriginal: ${originalRequested} staff\nNew: ${totalRequested} staff\nCurrently Assigned: ${assignedCount} staff\n\nExcess: ${assignedCount - totalRequested} staff must be removed.\n\nPlease log in to adjust assignments.`
}).catch(console.error);
}
toast({
title: "⚠️ Headcount Reduced",
description: "Vendor has been notified to adjust staff assignments",
});
return;
}
// Normal update
updateEventMutation.mutate({
id: eventId,
data: {
...eventData,
requested: totalRequested
}
});
};
const handleAutoUnassign = async () => {
if (!pendingUpdate) return;
const assignedStaff = event.assigned_staff || [];
const excessCount = assignedStaff.length - pendingUpdate.requested;
// Calculate reliability scores for assigned staff
const staffWithScores = assignedStaff.map(assigned => {
const staffData = allStaff.find(s => s.id === assigned.staff_id);
return {
...assigned,
reliability: staffData?.reliability_score || 50,
total_shifts: staffData?.total_shifts || 0,
no_shows: staffData?.no_show_count || 0,
cancellations: staffData?.cancellation_count || 0
};
});
// Sort by reliability (lowest first)
staffWithScores.sort((a, b) => a.reliability - b.reliability);
// Remove lowest reliability staff
const staffToKeep = staffWithScores.slice(excessCount);
await updateEventMutation.mutateAsync({
id: eventId,
data: {
...pendingUpdate,
assigned_staff: staffToKeep.map(s => ({
staff_id: s.staff_id,
staff_name: s.staff_name,
email: s.email,
role: s.role
}))
}
});
setShowReductionAlert(false);
setPendingUpdate(null);
toast({
title: "✅ Staff Auto-Unassigned",
description: `Removed ${excessCount} lowest reliability staff members`,
});
};
const handleManualUnassign = () => {
setShowReductionAlert(false);
toast({
title: "Manual Adjustment Required",
description: "Please manually remove excess staff from the order",
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
);
}
if (!event) {
return (
<div className="p-8 text-center">
<h2 className="text-2xl font-bold text-slate-900 mb-4">Event Not Found</h2>
<Button onClick={() => navigate(createPageUrl("Events"))}>
Back to Events
</Button>
</div>
);
}
return (
<div className="p-4 md:p-8">
<div className="max-w-7xl mx-auto">
<div className="mb-8">
<Button
variant="ghost"
onClick={() => navigate(createPageUrl("Events"))}
className="mb-4 hover:bg-slate-100"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Events
</Button>
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 mb-2">Edit Event</h1>
<p className="text-slate-600">Update information for {event.event_name}</p>
</div>
{showReductionAlert && pendingUpdate && (
<div className="mb-6">
<OrderReductionAlert
originalRequested={originalRequested}
newRequested={pendingUpdate.requested}
currentAssigned={event.assigned_staff?.length || 0}
onAutoUnassign={handleAutoUnassign}
onManualUnassign={handleManualUnassign}
lowReliabilityStaff={(event.assigned_staff || []).map(assigned => {
const staffData = allStaff.find(s => s.id === assigned.staff_id);
return {
name: assigned.staff_name,
reliability: staffData?.reliability_score || 50
};
}).sort((a, b) => a.reliability - b.reliability)}
/>
</div>
)}
<EventFormWizard
event={event}
onSubmit={handleSubmit}
isSubmitting={updateEventMutation.isPending}
currentUser={user}
onCancel={() => navigate(createPageUrl("Events"))}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,375 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Briefcase, ArrowLeft, Save, Plus, X, Loader2 } from "lucide-react";
import PageHeader from "../components/common/PageHeader";
import { useToast } from "@/components/ui/use-toast";
export default function EditPartner() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const urlParams = new URLSearchParams(window.location.search);
const partnerId = urlParams.get('id');
const { data: partners = [] } = useQuery({
queryKey: ['partners'],
queryFn: () => base44.entities.Partner.list(),
initialData: [],
});
const { data: sectors = [] } = useQuery({
queryKey: ['sectors'],
queryFn: () => base44.entities.Sector.list(),
initialData: [],
});
const partner = partners.find(p => p.id === partnerId);
const [partnerData, setPartnerData] = useState({
partner_name: "",
partner_number: "",
partner_type: "Corporate",
sector_id: "",
sector_name: "",
primary_contact_name: "",
primary_contact_email: "",
primary_contact_phone: "",
billing_address: "",
sites: [],
payment_terms: "Net 30",
is_active: true
});
const [newSite, setNewSite] = useState({
site_name: "",
address: "",
city: "",
state: "",
zip_code: "",
site_manager: "",
site_manager_email: ""
});
React.useEffect(() => {
if (partner) {
setPartnerData({
partner_name: partner.partner_name || "",
partner_number: partner.partner_number || "",
partner_type: partner.partner_type || "Corporate",
sector_id: partner.sector_id || "",
sector_name: partner.sector_name || "",
primary_contact_name: partner.primary_contact_name || "",
primary_contact_email: partner.primary_contact_email || "",
primary_contact_phone: partner.primary_contact_phone || "",
billing_address: partner.billing_address || "",
sites: partner.sites || [],
payment_terms: partner.payment_terms || "Net 30",
is_active: partner.is_active !== undefined ? partner.is_active : true
});
}
}, [partner]);
const updatePartnerMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Partner.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['partners'] });
toast({
title: "Partner Updated",
description: "Partner information has been successfully updated",
});
navigate(createPageUrl("PartnerManagement"));
},
});
const handleSubmit = (e) => {
e.preventDefault();
updatePartnerMutation.mutate({ id: partnerId, data: partnerData });
};
const handleSectorChange = (sectorId) => {
const sector = sectors.find(s => s.id === sectorId);
setPartnerData({
...partnerData,
sector_id: sectorId,
sector_name: sector?.sector_name || ""
});
};
const handleAddSite = () => {
if (newSite.site_name && newSite.address) {
setPartnerData({
...partnerData,
sites: [...partnerData.sites, newSite]
});
setNewSite({
site_name: "",
address: "",
city: "",
state: "",
zip_code: "",
site_manager: "",
site_manager_email: ""
});
}
};
const handleRemoveSite = (index) => {
setPartnerData({
...partnerData,
sites: partnerData.sites.filter((_, i) => i !== index)
});
};
if (!partner) {
return (
<div className="p-8 text-center">
<Loader2 className="w-8 h-8 animate-spin text-[#0A39DF] mx-auto mb-4" />
<h2 className="text-2xl font-bold text-slate-900 mb-4">Loading Partner...</h2>
</div>
);
}
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-4xl mx-auto">
<PageHeader
title="Edit Partner"
subtitle={`Update information for ${partner.partner_name}`}
backTo={createPageUrl("PartnerManagement")}
backButtonLabel="Back to Partners"
/>
<form onSubmit={handleSubmit}>
<Card className="mb-6 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle className="flex items-center gap-2">
<Briefcase className="w-5 h-5 text-[#0A39DF]" />
Basic Information
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label htmlFor="partner_number">Partner Number</Label>
<Input
id="partner_number"
value={partnerData.partner_number}
readOnly
className="bg-slate-50 font-mono"
/>
<p className="text-xs text-slate-500 mt-1">Auto-generated unique ID</p>
</div>
<div>
<Label htmlFor="partner_type">Partner Type *</Label>
<Select onValueChange={(value) => setPartnerData({...partnerData, partner_type: value})} value={partnerData.partner_type}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Corporate">Corporate</SelectItem>
<SelectItem value="Education">Education</SelectItem>
<SelectItem value="Healthcare">Healthcare</SelectItem>
<SelectItem value="Sports & Entertainment">Sports & Entertainment</SelectItem>
<SelectItem value="Government">Government</SelectItem>
</SelectContent>
</Select>
</div>
<div className="md:col-span-2">
<Label htmlFor="partner_name">Partner Name *</Label>
<Input
id="partner_name"
placeholder="e.g., Google"
value={partnerData.partner_name}
onChange={(e) => setPartnerData({...partnerData, partner_name: e.target.value})}
required
/>
</div>
<div>
<Label htmlFor="sector">Sector</Label>
<Select onValueChange={handleSectorChange} value={partnerData.sector_id}>
<SelectTrigger>
<SelectValue placeholder="Select sector" />
</SelectTrigger>
<SelectContent>
{sectors.map((sector) => (
<SelectItem key={sector.id} value={sector.id}>
{sector.sector_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="payment_terms">Payment Terms</Label>
<Input
id="payment_terms"
placeholder="Net 30"
value={partnerData.payment_terms}
onChange={(e) => setPartnerData({...partnerData, payment_terms: e.target.value})}
/>
</div>
<div>
<Label htmlFor="primary_contact_name">Primary Contact Name</Label>
<Input
id="primary_contact_name"
placeholder="John Doe"
value={partnerData.primary_contact_name}
onChange={(e) => setPartnerData({...partnerData, primary_contact_name: e.target.value})}
/>
</div>
<div>
<Label htmlFor="primary_contact_email">Primary Contact Email</Label>
<Input
id="primary_contact_email"
type="email"
placeholder="john@company.com"
value={partnerData.primary_contact_email}
onChange={(e) => setPartnerData({...partnerData, primary_contact_email: e.target.value})}
/>
</div>
<div>
<Label htmlFor="primary_contact_phone">Primary Contact Phone</Label>
<Input
id="primary_contact_phone"
placeholder="(555) 123-4567"
value={partnerData.primary_contact_phone}
onChange={(e) => setPartnerData({...partnerData, primary_contact_phone: e.target.value})}
/>
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between">
<Label htmlFor="is_active">Active Status</Label>
<Switch
id="is_active"
checked={partnerData.is_active}
onCheckedChange={(checked) => setPartnerData({...partnerData, is_active: checked})}
/>
</div>
<p className="text-xs text-slate-500 mt-1">Toggle to activate or deactivate this partner</p>
</div>
</div>
<div>
<Label htmlFor="billing_address">Billing Address</Label>
<Textarea
id="billing_address"
placeholder="123 Main St, City, State ZIP"
value={partnerData.billing_address}
onChange={(e) => setPartnerData({...partnerData, billing_address: e.target.value})}
rows={2}
/>
</div>
</CardContent>
</Card>
<Card className="mb-6 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle>Sites/Locations</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-slate-50 rounded-lg">
<Input
placeholder="Site Name"
value={newSite.site_name}
onChange={(e) => setNewSite({...newSite, site_name: e.target.value})}
/>
<Input
placeholder="Address"
value={newSite.address}
onChange={(e) => setNewSite({...newSite, address: e.target.value})}
/>
<Input
placeholder="City"
value={newSite.city}
onChange={(e) => setNewSite({...newSite, city: e.target.value})}
/>
<Input
placeholder="State"
value={newSite.state}
onChange={(e) => setNewSite({...newSite, state: e.target.value})}
/>
<Input
placeholder="ZIP Code"
value={newSite.zip_code}
onChange={(e) => setNewSite({...newSite, zip_code: e.target.value})}
/>
<Button type="button" onClick={handleAddSite} variant="outline" className="w-full">
<Plus className="w-4 h-4 mr-2" />
Add Site
</Button>
</div>
{partnerData.sites.length > 0 && (
<div className="space-y-2">
{partnerData.sites.map((site, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-white border rounded-lg">
<div>
<p className="font-semibold">{site.site_name}</p>
<p className="text-sm text-slate-500">{site.address}, {site.city}, {site.state} {site.zip_code}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveSite(index)}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate(createPageUrl("PartnerManagement"))}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
disabled={updatePartnerMutation.isPending}
>
{updatePartnerMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Updating...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Update Partner
</>
)}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,239 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MapPin, ArrowLeft, Save, Loader2 } from "lucide-react";
import PageHeader from "../components/common/PageHeader";
import { useToast } from "@/components/ui/use-toast";
export default function EditSector() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const urlParams = new URLSearchParams(window.location.search);
const sectorId = urlParams.get('id');
const { data: sectors = [] } = useQuery({
queryKey: ['sectors'],
queryFn: () => base44.entities.Sector.list(),
initialData: [],
});
const { data: enterprises = [] } = useQuery({
queryKey: ['enterprises'],
queryFn: () => base44.entities.Enterprise.list(),
initialData: [],
});
const sector = sectors.find(s => s.id === sectorId);
const [sectorData, setSectorData] = useState({
sector_number: "",
sector_name: "",
sector_code: "",
parent_enterprise_id: "",
parent_enterprise_name: "",
sector_type: "Food Service",
sector_policies: {
uniform_requirements: "",
certification_requirements: [],
special_training: []
},
is_active: true
});
React.useEffect(() => {
if (sector) {
setSectorData({
sector_number: sector.sector_number || "",
sector_name: sector.sector_name || "",
sector_code: sector.sector_code || "",
parent_enterprise_id: sector.parent_enterprise_id || "",
parent_enterprise_name: sector.parent_enterprise_name || "",
sector_type: sector.sector_type || "Food Service",
sector_policies: sector.sector_policies || {
uniform_requirements: "",
certification_requirements: [],
special_training: []
},
is_active: sector.is_active !== undefined ? sector.is_active : true
});
}
}, [sector]);
const updateSectorMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Sector.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sectors'] });
toast({
title: "Sector Updated",
description: "Sector has been successfully updated",
});
navigate(createPageUrl("SectorManagement"));
},
});
const handleSubmit = (e) => {
e.preventDefault();
updateSectorMutation.mutate({ id: sectorId, data: sectorData });
};
const handleEnterpriseChange = (enterpriseId) => {
const enterprise = enterprises.find(e => e.id === enterpriseId);
setSectorData({
...sectorData,
parent_enterprise_id: enterpriseId,
parent_enterprise_name: enterprise?.enterprise_name || ""
});
};
if (!sector) {
return (
<div className="p-8 text-center">
<Loader2 className="w-8 h-8 animate-spin text-[#0A39DF] mx-auto mb-4" />
<h2 className="text-2xl font-bold text-slate-900 mb-4">Loading Sector...</h2>
</div>
);
}
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-4xl mx-auto">
<PageHeader
title="Edit Sector"
subtitle={`Update information for ${sector.sector_name}`}
backTo={createPageUrl("SectorManagement")}
backButtonLabel="Back to Sectors"
/>
<form onSubmit={handleSubmit}>
<Card className="mb-6 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-[#0A39DF]" />
Basic Information
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label htmlFor="sector_number">Sector Number</Label>
<Input
id="sector_number"
value={sectorData.sector_number}
readOnly
className="bg-slate-50 font-mono"
/>
<p className="text-xs text-slate-500 mt-1">Auto-generated unique ID</p>
</div>
<div>
<Label htmlFor="sector_code">Sector Code *</Label>
<Input
id="sector_code"
placeholder="e.g., BON"
value={sectorData.sector_code}
onChange={(e) => setSectorData({...sectorData, sector_code: e.target.value.toUpperCase()})}
required
/>
</div>
<div className="md:col-span-2">
<Label htmlFor="sector_name">Sector Name *</Label>
<Input
id="sector_name"
placeholder="e.g., Bon Appétit"
value={sectorData.sector_name}
onChange={(e) => setSectorData({...sectorData, sector_name: e.target.value})}
required
/>
</div>
<div>
<Label htmlFor="parent_enterprise">Parent Enterprise</Label>
<Select onValueChange={handleEnterpriseChange} value={sectorData.parent_enterprise_id}>
<SelectTrigger>
<SelectValue placeholder="Select enterprise" />
</SelectTrigger>
<SelectContent>
{enterprises.map((enterprise) => (
<SelectItem key={enterprise.id} value={enterprise.id}>
{enterprise.enterprise_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="sector_type">Sector Type *</Label>
<Select onValueChange={(value) => setSectorData({...sectorData, sector_type: value})} value={sectorData.sector_type}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Food Service">Food Service</SelectItem>
<SelectItem value="Facilities">Facilities</SelectItem>
<SelectItem value="Healthcare">Healthcare</SelectItem>
<SelectItem value="Education">Education</SelectItem>
<SelectItem value="Corporate">Corporate</SelectItem>
<SelectItem value="Sports & Entertainment">Sports & Entertainment</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="uniform_requirements">Uniform Requirements</Label>
<Input
id="uniform_requirements"
placeholder="e.g., Black chef coat, black pants, non-slip shoes"
value={sectorData.sector_policies?.uniform_requirements || ""}
onChange={(e) => setSectorData({
...sectorData,
sector_policies: {...sectorData.sector_policies, uniform_requirements: e.target.value}
})}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate(createPageUrl("SectorManagement"))}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
disabled={updateSectorMutation.isPending}
>
{updateSectorMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Updating...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Update Sector
</>
)}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import React, { useState, useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Loader2 } from "lucide-react";
import StaffForm from "../components/staff/StaffForm";
export default function EditStaff() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const urlParams = new URLSearchParams(window.location.search);
const staffId = urlParams.get('id');
const { data: allStaff, isLoading } = useQuery({
queryKey: ['staff'],
queryFn: () => base44.entities.Staff.list(),
initialData: [],
});
const staff = allStaff.find(s => s.id === staffId);
const updateStaffMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Staff.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['staff'] });
navigate(createPageUrl("Dashboard"));
},
});
const handleSubmit = (staffData) => {
updateStaffMutation.mutate({ id: staffId, data: staffData });
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
);
}
if (!staff) {
return (
<div className="p-8 text-center">
<h2 className="text-2xl font-bold text-slate-900 mb-4">Staff Member Not Found</h2>
<Button onClick={() => navigate(createPageUrl("Dashboard"))}>
Back to Dashboard
</Button>
</div>
);
}
return (
<div className="p-4 md:p-8">
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<Button
variant="ghost"
onClick={() => navigate(createPageUrl("Dashboard"))}
className="mb-4 hover:bg-slate-100"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Dashboard
</Button>
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 mb-2">Edit Staff Member</h1>
<p className="text-slate-600">Update information for {staff.employee_name}</p>
</div>
<StaffForm
staff={staff}
onSubmit={handleSubmit}
isSubmitting={updateStaffMutation.isPending}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,418 @@
import React, { useState, useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Building2, ArrowLeft, Save, Loader2 } from "lucide-react";
import PageHeader from "../components/common/PageHeader";
import { useToast } from "@/components/ui/use-toast";
export default function EditVendor() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const urlParams = new URLSearchParams(window.location.search);
const vendorId = urlParams.get('id');
const { data: allVendors = [], isLoading } = useQuery({
queryKey: ['vendors'],
queryFn: () => base44.entities.Vendor.list(),
initialData: [],
});
const vendor = allVendors.find(v => v.id === vendorId);
const [vendorData, setVendorData] = useState({
vendor_number: "",
legal_name: "",
doing_business_as: "",
tax_id: "",
business_type: "LLC",
primary_contact_name: "",
primary_contact_email: "",
primary_contact_phone: "",
billing_address: "",
service_address: "",
coverage_regions: [],
eligible_roles: [],
insurance_expiry: "",
approval_status: "approved",
is_active: true,
notes: ""
});
const [regionInput, setRegionInput] = useState("");
const [roleInput, setRoleInput] = useState("");
useEffect(() => {
if (vendor) {
setVendorData({
vendor_number: vendor.vendor_number || "",
legal_name: vendor.legal_name || "",
doing_business_as: vendor.doing_business_as || "",
tax_id: vendor.tax_id || "",
business_type: vendor.business_type || "LLC",
primary_contact_name: vendor.primary_contact_name || "",
primary_contact_email: vendor.primary_contact_email || "",
primary_contact_phone: vendor.primary_contact_phone || "",
billing_address: vendor.billing_address || "",
service_address: vendor.service_address || "",
coverage_regions: vendor.coverage_regions || [],
eligible_roles: vendor.eligible_roles || [],
insurance_expiry: vendor.insurance_expiry || "",
approval_status: vendor.approval_status || "approved",
is_active: vendor.is_active !== undefined ? vendor.is_active : true,
notes: vendor.notes || ""
});
}
}, [vendor]);
const updateVendorMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Vendor.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vendors'] });
toast({
title: "Vendor Updated",
description: "Vendor information has been successfully updated",
});
navigate(createPageUrl("VendorManagement"));
},
});
const handleAddRegion = () => {
if (regionInput.trim() && !vendorData.coverage_regions.includes(regionInput.trim())) {
setVendorData({
...vendorData,
coverage_regions: [...vendorData.coverage_regions, regionInput.trim()]
});
setRegionInput("");
}
};
const handleRemoveRegion = (index) => {
setVendorData({
...vendorData,
coverage_regions: vendorData.coverage_regions.filter((_, i) => i !== index)
});
};
const handleAddRole = () => {
if (roleInput.trim() && !vendorData.eligible_roles.includes(roleInput.trim())) {
setVendorData({
...vendorData,
eligible_roles: [...vendorData.eligible_roles, roleInput.trim()]
});
setRoleInput("");
}
};
const handleRemoveRole = (index) => {
setVendorData({
...vendorData,
eligible_roles: vendorData.eligible_roles.filter((_, i) => i !== index)
});
};
const handleSubmit = (e) => {
e.preventDefault();
updateVendorMutation.mutate({ id: vendorId, data: vendorData });
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-8 h-8 animate-spin text-[#0A39DF]" />
</div>
);
}
if (!vendor) {
return (
<div className="p-8 text-center">
<h2 className="text-2xl font-bold text-slate-900 mb-4">Vendor Not Found</h2>
<Button onClick={() => navigate(createPageUrl("VendorManagement"))}>
Back to Vendors
</Button>
</div>
);
}
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-4xl mx-auto">
<PageHeader
title="Edit Vendor"
subtitle={`Update information for ${vendor.legal_name || vendor.doing_business_as}`}
backTo={createPageUrl("VendorManagement")}
backButtonLabel="Back to Vendors"
/>
<form onSubmit={handleSubmit}>
{/* Basic Information */}
<Card className="mb-6 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle className="flex items-center gap-2">
<Building2 className="w-5 h-5 text-[#0A39DF]" />
Basic Information
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label htmlFor="vendor_number">Vendor Number</Label>
<Input
id="vendor_number"
value={vendorData.vendor_number}
disabled
className="bg-slate-100"
/>
</div>
<div>
<Label htmlFor="approval_status">Status</Label>
<Select
value={vendorData.approval_status}
onValueChange={(value) => setVendorData({...vendorData, approval_status: value})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="suspended">Suspended</SelectItem>
<SelectItem value="terminated">Terminated</SelectItem>
</SelectContent>
</Select>
</div>
<div className="md:col-span-2">
<Label htmlFor="legal_name">Legal Business Name *</Label>
<Input
id="legal_name"
placeholder="e.g., ABC Staffing LLC"
value={vendorData.legal_name}
onChange={(e) => setVendorData({...vendorData, legal_name: e.target.value})}
required
/>
</div>
<div className="md:col-span-2">
<Label htmlFor="doing_business_as">Doing Business As (DBA)</Label>
<Input
id="doing_business_as"
placeholder="e.g., ABC Staff"
value={vendorData.doing_business_as}
onChange={(e) => setVendorData({...vendorData, doing_business_as: e.target.value})}
/>
</div>
<div>
<Label htmlFor="tax_id">Federal Tax ID / EIN</Label>
<Input
id="tax_id"
placeholder="12-3456789"
value={vendorData.tax_id}
onChange={(e) => setVendorData({...vendorData, tax_id: e.target.value})}
/>
</div>
<div>
<Label htmlFor="business_type">Business Type</Label>
<Select
value={vendorData.business_type}
onValueChange={(value) => setVendorData({...vendorData, business_type: value})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Corporation">Corporation</SelectItem>
<SelectItem value="LLC">LLC</SelectItem>
<SelectItem value="Partnership">Partnership</SelectItem>
<SelectItem value="Sole Proprietorship">Sole Proprietorship</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Contact Information */}
<Card className="mb-6 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle>Primary Contact</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<Label htmlFor="primary_contact_name">Contact Name</Label>
<Input
id="primary_contact_name"
placeholder="John Doe"
value={vendorData.primary_contact_name}
onChange={(e) => setVendorData({...vendorData, primary_contact_name: e.target.value})}
/>
</div>
<div>
<Label htmlFor="primary_contact_email">Email *</Label>
<Input
id="primary_contact_email"
type="email"
placeholder="john@vendor.com"
value={vendorData.primary_contact_email}
onChange={(e) => setVendorData({...vendorData, primary_contact_email: e.target.value})}
required
/>
</div>
<div>
<Label htmlFor="primary_contact_phone">Phone</Label>
<Input
id="primary_contact_phone"
type="tel"
placeholder="(555) 123-4567"
value={vendorData.primary_contact_phone}
onChange={(e) => setVendorData({...vendorData, primary_contact_phone: e.target.value})}
/>
</div>
</div>
</CardContent>
</Card>
{/* Coverage & Services */}
<Card className="mb-6 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle>Coverage & Services</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
<div>
<Label>Coverage Regions</Label>
<div className="flex gap-2 mb-2">
<Input
placeholder="Add region (e.g., San Francisco Bay Area)"
value={regionInput}
onChange={(e) => setRegionInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddRegion())}
/>
<Button type="button" onClick={handleAddRegion} variant="outline">
Add
</Button>
</div>
<div className="flex flex-wrap gap-2">
{vendorData.coverage_regions.map((region, i) => (
<span key={i} className="bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-sm flex items-center gap-2">
{region}
<button type="button" onClick={() => handleRemoveRegion(i)} className="text-blue-900 hover:text-blue-700">×</button>
</span>
))}
</div>
</div>
<div>
<Label>Eligible Roles</Label>
<div className="flex gap-2 mb-2">
<Input
placeholder="Add role (e.g., Server, Bartender)"
value={roleInput}
onChange={(e) => setRoleInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddRole())}
/>
<Button type="button" onClick={handleAddRole} variant="outline">
Add
</Button>
</div>
<div className="flex flex-wrap gap-2">
{vendorData.eligible_roles.map((role, i) => (
<span key={i} className="bg-purple-100 text-purple-700 px-3 py-1 rounded-full text-sm flex items-center gap-2">
{role}
<button type="button" onClick={() => handleRemoveRole(i)} className="text-purple-900 hover:text-purple-700">×</button>
</span>
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<Label htmlFor="billing_address">Billing Address</Label>
<Textarea
id="billing_address"
rows={3}
placeholder="123 Main St, San Francisco, CA 94102"
value={vendorData.billing_address}
onChange={(e) => setVendorData({...vendorData, billing_address: e.target.value})}
/>
</div>
<div className="md:col-span-2">
<Label htmlFor="service_address">Service Address</Label>
<Textarea
id="service_address"
rows={3}
placeholder="456 Service St, San Francisco, CA 94103"
value={vendorData.service_address}
onChange={(e) => setVendorData({...vendorData, service_address: e.target.value})}
/>
</div>
</div>
</CardContent>
</Card>
{/* Notes */}
<Card className="mb-6 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle>Additional Information</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div>
<Label htmlFor="notes">Internal Notes</Label>
<Textarea
id="notes"
rows={4}
placeholder="Add any internal notes about this vendor..."
value={vendorData.notes}
onChange={(e) => setVendorData({...vendorData, notes: e.target.value})}
/>
</div>
</CardContent>
</Card>
{/* Action Buttons */}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate(createPageUrl("VendorManagement"))}
>
Cancel
</Button>
<Button
type="submit"
disabled={updateVendorMutation.isPending}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
>
{updateVendorMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Updating...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Update Vendor
</>
)}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Building2, Plus, Search, Users, Edit } from "lucide-react";
import PageHeader from "../components/common/PageHeader";
export default function EnterpriseManagement() {
const [searchTerm, setSearchTerm] = useState("");
const { data: enterprises = [], isLoading } = useQuery({
queryKey: ['enterprises'],
queryFn: () => base44.entities.Enterprise.list('-created_date'),
initialData: [],
});
const filteredEnterprises = enterprises.filter(e =>
!searchTerm ||
e.enterprise_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
e.enterprise_code?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<PageHeader
title="Enterprise Management"
subtitle={`${filteredEnterprises.length} enterprises • Top-level operators`}
actions={
<Link to={createPageUrl("AddEnterprise")}>
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
<Plus className="w-4 h-4 mr-2" />
Add Enterprise
</Button>
</Link>
}
/>
{/* Search */}
<Card className="mb-6 border-slate-200">
<CardContent className="p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
placeholder="Search enterprises..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</CardContent>
</Card>
{/* Enterprises Grid */}
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-64 bg-slate-100 animate-pulse rounded-xl" />
))}
</div>
) : filteredEnterprises.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{filteredEnterprises.map((enterprise) => (
<Card key={enterprise.id} className="border-2 border-slate-200 hover:border-[#0A39DF] hover:shadow-xl transition-all">
<CardContent className="p-6">
<div className="flex items-start gap-4 mb-4">
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500 to-indigo-700 rounded-xl flex items-center justify-center text-white font-bold text-2xl">
{enterprise.enterprise_code}
</div>
<div className="flex-1">
<h3 className="font-bold text-xl text-[#1C323E] mb-1">
{enterprise.enterprise_name}
</h3>
<p className="text-sm text-slate-500">{enterprise.headquarters_address}</p>
</div>
<Link to={createPageUrl(`EditEnterprise?id=${enterprise.id}`)}>
<Button variant="ghost" size="icon" className="text-slate-400 hover:text-[#0A39DF] hover:bg-blue-50">
<Edit className="w-4 h-4" />
</Button>
</Link>
</div>
{enterprise.brand_family && enterprise.brand_family.length > 0 && (
<div className="mb-4">
<p className="text-sm font-semibold text-slate-700 mb-2">Brand Family</p>
<div className="flex flex-wrap gap-2">
{enterprise.brand_family.map((brand, i) => (
<Badge key={i} variant="outline" className="text-xs">
{brand}
</Badge>
))}
</div>
</div>
)}
{enterprise.sector_registry && enterprise.sector_registry.length > 0 && (
<div className="flex items-center gap-2 pt-4 border-t border-slate-200">
<Users className="w-4 h-4 text-slate-500" />
<span className="text-sm text-slate-600">
{enterprise.sector_registry.length} Sectors
</span>
</div>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Card className="border-slate-200">
<CardContent className="p-12 text-center">
<Building2 className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Enterprises Found</h3>
<p className="text-slate-500 mb-6">Add your first enterprise</p>
<Link to={createPageUrl("AddEnterprise")}>
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
<Plus className="w-4 h-4 mr-2" />
Add First Enterprise
</Button>
</Link>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,355 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ArrowLeft, Calendar, MapPin, Users, DollarSign, Send, Edit3, X, AlertTriangle } from "lucide-react";
import ShiftCard from "../components/events/ShiftCard";
import OrderStatusBadge from "../components/orders/OrderStatusBadge";
import CancellationFeeModal from "../components/orders/CancellationFeeModal";
import { useToast } from "../components/ui/use-toast";
import { format } from "date-fns";
const safeFormatDate = (dateString) => {
if (!dateString) return "—";
try {
// If date is in format YYYY-MM-DD, parse it without timezone conversion
if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
const [year, month, day] = dateString.split('-').map(Number);
const date = new Date(year, month - 1, day);
return format(date, "MMMM d, yyyy");
}
return format(new Date(dateString), "MMMM d, yyyy");
} catch {
return "—";
}
};
export default function EventDetail() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const [notifyDialog, setNotifyDialog] = useState(false);
const [cancelDialog, setCancelDialog] = useState(false);
const [showCancellationFeeModal, setShowCancellationFeeModal] = useState(false);
const urlParams = new URLSearchParams(window.location.search);
const eventId = urlParams.get("id");
const { data: user } = useQuery({
queryKey: ['current-user-event-detail'],
queryFn: () => base44.auth.me(),
});
const { data: allEvents, isLoading } = useQuery({
queryKey: ['events'],
queryFn: () => base44.entities.Event.list(),
initialData: [],
});
const event = allEvents.find(e => e.id === eventId);
// Cancel order mutation
const cancelOrderMutation = useMutation({
mutationFn: () => base44.entities.Event.update(eventId, { status: "Canceled" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: ['all-events-client'] });
// Notify vendor
if (event.vendor_name && event.vendor_id) {
base44.integrations.Core.SendEmail({
to: `${event.vendor_name}@example.com`,
subject: `Order Canceled: ${event.event_name}`,
body: `Client has canceled order: ${event.event_name}\nDate: ${event.date}\nLocation: ${event.hub || event.event_location}`
}).catch(console.error);
}
toast({
title: "✅ Order Canceled",
description: "Your order has been canceled successfully",
});
setShowCancellationFeeModal(false);
navigate(createPageUrl("ClientOrders"));
},
onError: () => {
toast({
title: "❌ Failed to Cancel",
description: "Could not cancel order. Please try again.",
variant: "destructive",
});
},
});
const handleCancelClick = () => {
setShowCancellationFeeModal(true);
};
const handleConfirmCancellation = () => {
cancelOrderMutation.mutate();
};
const handleNotifyStaff = async () => {
const assignedStaff = event?.assigned_staff || [];
for (const staff of assignedStaff) {
try {
await base44.integrations.Core.SendEmail({
to: staff.email || `${staff.staff_name}@example.com`,
subject: `Shift Update: ${event.event_name}`,
body: `You have an update for: ${event.event_name}\nDate: ${event.date}\nLocation: ${event.event_location || event.hub}\n\nPlease check the platform for details.`
});
} catch (error) {
console.error("Failed to send email:", error);
}
}
toast({
title: "✅ Notifications Sent",
description: `Notified ${assignedStaff.length} staff members`,
});
setNotifyDialog(false);
};
const isClient = user?.user_role === 'client' ||
event?.created_by === user?.email ||
event?.client_email === user?.email;
const canEditOrder = () => {
if (!event) return false;
const eventDate = new Date(event.date);
const now = new Date();
return isClient &&
event.status !== "Completed" &&
event.status !== "Canceled" &&
eventDate > now;
};
const canCancelOrder = () => {
if (!event) return false;
return isClient &&
event.status !== "Completed" &&
event.status !== "Canceled";
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
</div>
);
}
if (!event) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-xl font-semibold text-slate-900 mb-4">Event not found</p>
<Link to={createPageUrl("Events")}>
<Button variant="outline">Back to Events</Button>
</Link>
</div>
);
}
// Get shifts from event.shifts array (primary source)
const eventShifts = event.shifts || [];
return (
<div className="p-4 md:p-8">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div>
<h1 className="text-3xl font-bold text-slate-900">{event.event_name}</h1>
<p className="text-slate-600 mt-1">Order Details & Information</p>
</div>
</div>
<div className="flex items-center gap-3">
<OrderStatusBadge order={event} />
{canEditOrder() && (
<button
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
className="flex items-center gap-2 px-5 py-2.5 bg-white hover:bg-blue-50 border-2 border-blue-200 rounded-full text-blue-600 font-semibold text-base transition-all shadow-md hover:shadow-lg"
>
<Edit3 className="w-5 h-5" />
Edit
</button>
)}
{canCancelOrder() && (
<button
onClick={handleCancelClick}
className="flex items-center gap-2 px-5 py-2.5 bg-white hover:bg-red-50 border-2 border-red-200 rounded-full text-red-600 font-semibold text-base transition-all shadow-md hover:shadow-lg"
>
<X className="w-5 h-5" />
Cancel Order
</button>
)}
{!isClient && event.assigned_staff?.length > 0 && (
<Button
onClick={() => setNotifyDialog(true)}
className="bg-blue-600 hover:bg-blue-700"
>
<Send className="w-4 h-4 mr-2" />
Notify Staff
</Button>
)}
</div>
</div>
{/* Order Details Card */}
<Card className="bg-white border border-slate-200 shadow-md">
<CardHeader className="border-b border-slate-100">
<CardTitle className="text-lg font-bold text-slate-900">Order Information</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-4 gap-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
<Calendar className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="text-xs text-slate-500">Event Date</p>
<p className="font-bold text-slate-900">{safeFormatDate(event.date)}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-50 rounded-lg flex items-center justify-center">
<MapPin className="w-5 h-5 text-purple-600" />
</div>
<div>
<p className="text-xs text-slate-500">Location</p>
<p className="font-bold text-slate-900">{event.hub || event.event_location || "—"}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-50 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-green-600" />
</div>
<div>
<p className="text-xs text-slate-500">Staff Assigned</p>
<p className="font-bold text-slate-900">
{event.assigned_staff?.length || 0} / {event.requested || 0}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-50 rounded-lg flex items-center justify-center">
<DollarSign className="w-5 h-5 text-amber-600" />
</div>
<div>
<p className="text-xs text-slate-500">Total Cost</p>
<p className="font-bold text-slate-900">${(event.total || 0).toLocaleString()}</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Client Information (if not client viewing) */}
{!isClient && (
<Card className="bg-white border border-slate-200 shadow-md">
<CardHeader className="border-b border-slate-100">
<CardTitle className="text-lg font-bold text-slate-900">Client Information</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-3 gap-6">
<div>
<p className="text-xs text-slate-500 mb-1">Business Name</p>
<p className="font-bold text-slate-900">{event.business_name || "—"}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">Contact Name</p>
<p className="font-bold text-slate-900">{event.client_name || "—"}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">Contact Email</p>
<p className="font-bold text-slate-900">{event.client_email || "—"}</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Shifts - Using event.shifts array */}
<div className="space-y-4">
<h2 className="text-xl font-bold text-slate-900">Event Shifts & Staff Assignment</h2>
{eventShifts.length > 0 ? (
eventShifts.map((shift, idx) => (
<ShiftCard key={idx} shift={shift} event={event} currentUser={user} />
))
) : (
<Card className="bg-white border border-slate-200">
<CardContent className="p-12 text-center">
<Users className="w-12 h-12 mx-auto mb-4 text-slate-400" />
<p className="text-slate-600 font-medium mb-2">No shifts defined for this event</p>
<p className="text-slate-500 text-sm">Add roles and staff requirements to get started</p>
</CardContent>
</Card>
)}
</div>
{/* Notes */}
{event.notes && (
<Card className="bg-white border border-slate-200 shadow-md">
<CardHeader className="border-b border-slate-100">
<CardTitle className="text-lg font-bold text-slate-900">Additional Notes</CardTitle>
</CardHeader>
<CardContent className="p-6">
<p className="text-slate-700 whitespace-pre-wrap">{event.notes}</p>
</CardContent>
</Card>
)}
</div>
{/* Notify Staff Dialog */}
<Dialog open={notifyDialog} onOpenChange={setNotifyDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Notify Assigned Staff</DialogTitle>
<DialogDescription>
Send notification to all {event.assigned_staff?.length || 0} assigned staff members about this event.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setNotifyDialog(false)}>
Cancel
</Button>
<Button onClick={handleNotifyStaff} className="bg-blue-600 hover:bg-blue-700">
<Send className="w-4 h-4 mr-2" />
Send Notifications
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Cancellation Fee Modal */}
<CancellationFeeModal
open={showCancellationFeeModal}
onClose={() => setShowCancellationFeeModal(false)}
onConfirm={handleConfirmCancellation}
event={event}
isSubmitting={cancelOrderMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,710 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
import { Link, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Plus, Search, Calendar as CalendarIcon, Eye, Edit, Copy, X, RefreshCw, Users, Sparkles, Zap, Target, Clock, MapPin, Shield, DollarSign, TrendingUp, List, LayoutGrid, FileText, MoreVertical, AlertTriangle, CheckCircle, UserCog } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { format, isSameDay, parseISO, isWithinInterval, startOfDay, endOfDay, isValid } from "date-fns";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useToast } from "@/components/ui/use-toast";
import SmartAssignModal from "../components/events/SmartAssignModal";
import DragDropScheduler from "../components/scheduling/DragDropScheduler";
import AutomationEngine from "../components/scheduling/AutomationEngine";
import { autoFillShifts } from "../components/scheduling/SmartAssignmentEngine";
import { detectAllConflicts, ConflictAlert } from "../components/scheduling/ConflictDetection";
const safeParseDate = (dateString) => {
if (!dateString) return null;
try {
// If date is in format YYYY-MM-DD, parse it without timezone conversion
if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
const [year, month, day] = dateString.split('-').map(Number);
const date = new Date(year, month - 1, day);
return isValid(date) ? date : null;
}
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
return isValid(date) ? date : null;
} catch { return null; }
};
const safeFormatDate = (dateString, formatStr) => {
const date = safeParseDate(dateString);
if (!date) return "-";
try { return format(date, formatStr); } catch { return "-"; }
};
const convertTo12Hour = (time24) => {
if (!time24) return "-";
try {
const [hours, minutes] = time24.split(':');
const hour = parseInt(hours);
const ampm = hour >= 12 ? 'PM' : 'AM';
const hour12 = hour % 12 || 12;
return `${hour12}:${minutes} ${ampm}`;
} catch {
return time24;
}
};
const getStatusBadge = (event, hasConflicts) => {
if (event.is_rapid) {
return (
<div className="relative inline-flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md">
<Zap className="w-3.5 h-3.5 fill-white" />
RAPID
{hasConflicts && (
<AlertTriangle className="w-3 h-3 absolute -top-1 -right-1 text-orange-500 bg-white rounded-full p-0.5" />
)}
</div>
);
}
const statusConfig = {
'Draft': { bg: 'bg-slate-500', icon: FileText },
'Pending': { bg: 'bg-amber-500', icon: Clock },
'Partial Staffed': { bg: 'bg-orange-500', icon: AlertTriangle },
'Fully Staffed': { bg: 'bg-emerald-500', icon: CheckCircle },
'Active': { bg: 'bg-blue-500', icon: Users },
'Completed': { bg: 'bg-slate-400', icon: CheckCircle },
'Canceled': { bg: 'bg-red-500', icon: X },
};
const config = statusConfig[event.status] || { bg: 'bg-slate-400', icon: Clock };
const Icon = config.icon;
return (
<div className={`relative inline-flex items-center gap-2 ${config.bg} text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md`}>
<Icon className="w-3.5 h-3.5" />
{event.status}
{hasConflicts && (
<AlertTriangle className="w-3 h-3 absolute -top-1 -right-1 text-orange-500 bg-white rounded-full p-0.5" />
)}
</div>
);
};
export default function Events() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState("all");
const [viewMode, setViewMode] = useState("table");
const [searchTerm, setSearchTerm] = useState("");
const { toast } = useToast();
const [assignModal, setAssignModal] = useState({ open: false, event: null, shift: null, role: null });
const [isAutoAssigning, setIsAutoAssigning] = useState(false);
const [showConflicts, setShowConflicts] = useState(true);
const [assignmentOptions, setAssignmentOptions] = useState({
prioritizeSkill: true,
prioritizeReliability: true,
prioritizeVendor: true,
prioritizeFatigue: true,
prioritizeCompliance: true,
prioritizeProximity: true,
prioritizeCost: false,
});
const { data: events, isLoading } = useQuery({
queryKey: ['events'],
queryFn: () => base44.entities.Event.list('-date'),
initialData: [],
});
const { data: allStaff = [] } = useQuery({
queryKey: ['staff-for-auto-assign'],
queryFn: () => base44.entities.Staff.list(),
});
const { data: vendorRates = [] } = useQuery({
queryKey: ['vendor-rates-auto-assign'],
queryFn: () => base44.entities.VendorRate.list(),
initialData: [],
});
const updateEventMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Event.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
},
});
const eventsWithConflicts = React.useMemo(() => {
return events.map(event => {
const conflicts = detectAllConflicts(event, events);
return { ...event, detected_conflicts: conflicts };
});
}, [events]);
const totalConflicts = eventsWithConflicts.reduce((sum, e) => sum + (e.detected_conflicts?.length || 0), 0);
const autoAssignMutation = useMutation({
mutationFn: async (event) => {
const assignments = await autoFillShifts(event, allStaff, events, vendorRates, assignmentOptions);
if (assignments.length === 0) throw new Error("No suitable staff found");
const updatedAssignedStaff = [...(event.assigned_staff || []), ...assignments];
const updatedShifts = (event.shifts || []).map(shift => {
const updatedRoles = (shift.roles || []).map(role => {
const roleAssignments = assignments.filter(a => a.role === role.role);
return { ...role, assigned: (role.assigned || 0) + roleAssignments.length };
});
return { ...shift, roles: updatedRoles };
});
const totalRequested = updatedShifts.reduce((accShift, shift) => {
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
}, 0);
const totalAssigned = updatedAssignedStaff.length;
let newStatus = event.status;
if (totalAssigned >= totalRequested && totalRequested > 0) {
newStatus = 'Fully Staffed';
} else if (totalAssigned > 0 && totalAssigned < totalRequested) {
newStatus = 'Partial Staffed';
} else if (totalAssigned === 0) {
newStatus = 'Pending';
}
await base44.entities.Event.update(event.id, {
assigned_staff: updatedAssignedStaff,
shifts: updatedShifts,
requested: (event.requested || 0) + assignments.length,
status: newStatus,
});
return assignments.length;
},
onSuccess: (count) => {
queryClient.invalidateQueries({ queryKey: ['events'] });
toast({ title: "✅ Auto-Assigned", description: `Assigned ${count} staff automatically` });
},
onError: (error) => {
toast({ title: "⚠️ Auto-Assign Failed", description: error.message, variant: "destructive" });
},
});
const handleAssign = async (eventId, staffMember) => {
const event = events.find(e => e.id === eventId);
if (!event) return;
const updatedAssignedStaff = [
...(event.assigned_staff || []),
{ staff_id: staffMember.id, staff_name: staffMember.employee_name, email: staffMember.email, role: staffMember.position }
];
const totalRequested = event.shifts?.reduce((accShift, shift) => {
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
}, 0) || 0;
let newStatus = event.status;
if (updatedAssignedStaff.length >= totalRequested && totalRequested > 0) {
newStatus = 'Fully Staffed';
} else if (updatedAssignedStaff.length > 0 && updatedAssignedStaff.length < totalRequested) {
newStatus = 'Partial Staffed';
}
await updateEventMutation.mutateAsync({
id: eventId,
data: {
assigned_staff: updatedAssignedStaff,
status: newStatus,
}
});
toast({ title: "✅ Staff Assigned", description: `${staffMember.employee_name} assigned to event` });
};
const handleUnassign = async (eventId, staffId) => {
const event = events.find(e => e.id === eventId);
if (!event) return;
const updatedAssignedStaff = event.assigned_staff.filter(s => s.staff_id !== staffId);
const totalRequested = event.shifts?.reduce((accShift, shift) => {
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
}, 0) || 0;
let newStatus = event.status;
if (updatedAssignedStaff.length >= totalRequested && totalRequested > 0) {
newStatus = 'Fully Staffed';
} else if (updatedAssignedStaff.length > 0 && updatedAssignedStaff.length < totalRequested) {
newStatus = 'Partial Staffed';
} else if (updatedAssignedStaff.length === 0) {
newStatus = 'Pending';
}
await updateEventMutation.mutateAsync({
id: eventId,
data: {
assigned_staff: updatedAssignedStaff,
status: newStatus,
}
});
toast({ title: "Staff Unassigned", description: "Staff member removed from event" });
};
const handleAutoAssignEvent = (event) => autoAssignMutation.mutate(event);
const getStatusCounts = () => {
const total = events.length;
const active = events.filter(e => e.status === "Active").length;
const pending = events.filter(e => e.status === "Pending").length;
const partialStaffed = events.filter(e => e.status === "Partial Staffed").length;
const fullyStaffed = events.filter(e => e.status === "Fully Staffed").length;
const completed = events.filter(e => e.status === "Completed").length;
return {
active: { count: active, percentage: total ? Math.round((active / total) * 100) : 0 },
pending: { count: pending, percentage: total ? Math.round((pending / total) * 100) : 0 },
partialStaffed: { count: partialStaffed, percentage: total ? Math.round((partialStaffed / total) * 100) : 0 },
fullyStaffed: { count: fullyStaffed, percentage: total ? Math.round((fullyStaffed / total) * 100) : 0 },
completed: { count: completed, percentage: total ? Math.round((completed / total) * 100) : 0 },
};
};
const getFilteredEvents = () => {
let filtered = eventsWithConflicts;
if (activeTab === "last_minute") filtered = filtered.filter(e => e.event_type === "Last Minute Request");
else if (activeTab === "upcoming") filtered = filtered.filter(e => { const eventDate = safeParseDate(e.date); return eventDate && eventDate > new Date(); });
else if (activeTab === "active") filtered = filtered.filter(e => e.status === "Active");
else if (activeTab === "fully_staffed") filtered = filtered.filter(e => e.status === "Fully Staffed");
else if (activeTab === "canceled") filtered = filtered.filter(e => e.status === "Canceled");
else if (activeTab === "past") filtered = filtered.filter(e => e.status === "Completed");
else if (activeTab === "conflicts") filtered = filtered.filter(e => e.detected_conflicts && e.detected_conflicts.length > 0);
else if (activeTab === "draft") filtered = filtered.filter(e => e.status === "Draft");
if (searchTerm) {
filtered = filtered.filter(e =>
e.event_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
e.hub?.toLowerCase().includes(searchTerm.toLowerCase()) ||
e.business_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
e.id?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return filtered;
};
const statusCounts = getStatusCounts();
const filteredEvents = getFilteredEvents();
const unassignedStaff = allStaff.filter(staff => !events.some(e => e.assigned_staff?.some(s => s.staff_id === staff.id)));
const upcomingEvents = filteredEvents.filter(e => {
const eventDate = new Date(e.date);
return eventDate >= new Date() && e.status !== 'Completed' && e.status !== 'Canceled';
}).slice(0, 10);
const getTabCount = (tab) => {
if (tab === "all") return events.length;
if (tab === "conflicts") return eventsWithConflicts.filter(e => e.detected_conflicts && e.detected_conflicts.length > 0).length;
if (tab === "last_minute") return events.filter(e => e.event_type === "Last Minute Request").length;
if (tab === "upcoming") return events.filter(e => { const eventDate = safeParseDate(e.date); return eventDate && eventDate > new Date(); }).length;
if (tab === "active") return events.filter(e => e.status === "Active").length;
if (tab === "fully_staffed") return events.filter(e => e.status === "Fully Staffed").length;
if (tab === "canceled") return events.filter(e => e.status === "Canceled").length;
if (tab === "past") return events.filter(e => e.status === "Completed").length;
if (tab === "draft") return events.filter(e => e.status === "Draft").length;
return 0;
};
const getAssignmentStatus = (event) => {
const totalRequested = event.shifts?.reduce((accShift, shift) => {
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
}, 0) || 0;
const assigned = event.assigned_staff?.length || 0;
const fillPercent = totalRequested > 0 ? Math.round((assigned / totalRequested) * 100) : 0;
if (assigned === 0) return { color: 'bg-slate-100 text-slate-600', text: '0', percent: '0%', status: 'empty' };
if (totalRequested > 0 && assigned >= totalRequested) return { color: 'bg-emerald-500 text-white', text: assigned, percent: '100%', status: 'full' };
if (totalRequested > 0 && assigned < totalRequested) return { color: 'bg-orange-500 text-white', text: assigned, percent: `${fillPercent}%`, status: 'partial' };
return { color: 'bg-slate-500 text-white', text: assigned, percent: '0%', status: 'partial' };
};
const getEventTimes = (event) => {
const firstShift = event.shifts?.[0];
const rolesInFirstShift = firstShift?.roles || [];
let startTime = null;
let endTime = null;
if (rolesInFirstShift.length > 0) {
startTime = rolesInFirstShift[0].start_time || null;
endTime = rolesInFirstShift[0].end_time || null;
}
return {
startTime: startTime ? convertTo12Hour(startTime) : "-",
endTime: endTime ? convertTo12Hour(endTime) : "-"
};
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-[1800px] mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900">Order Management</h1>
<p className="text-sm text-slate-500 mt-1">View, assign, and track all your orders</p>
</div>
{showConflicts && totalConflicts > 0 && (
<Alert className="mb-6 border-2 border-orange-500 bg-orange-50">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-orange-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<AlertDescription className="font-semibold text-orange-900">
{totalConflicts} scheduling conflict{totalConflicts !== 1 ? 's' : ''} detected
</AlertDescription>
<p className="text-sm text-orange-700 mt-1">
Click the "Conflicts" tab to review and resolve overlapping bookings
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setShowConflicts(false)}
className="flex-shrink-0"
>
<X className="w-4 h-4" />
</Button>
</div>
</Alert>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="border border-red-200 bg-red-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-500 rounded-lg flex items-center justify-center">
<Zap className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-red-600 font-semibold uppercase">RAPID</p>
<p className="text-2xl font-bold text-red-700">{events.filter(e => e.is_rapid).length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-amber-200 bg-amber-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
<Clock className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-amber-600 font-semibold uppercase">REQUESTED</p>
<p className="text-2xl font-bold text-amber-700">{events.filter(e => e.status === 'Pending').length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-orange-200 bg-orange-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-orange-600 font-semibold uppercase">PARTIAL</p>
<p className="text-2xl font-bold text-orange-700">{events.filter(e => {
const status = getAssignmentStatus(e);
return status.status === 'partial';
}).length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-emerald-200 bg-emerald-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-emerald-500 rounded-lg flex items-center justify-center">
<CheckCircle className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-emerald-600 font-semibold uppercase">FULLY STAFFED</p>
<p className="text-2xl font-bold text-emerald-700">{events.filter(e => {
const status = getAssignmentStatus(e);
return status.status === 'full';
}).length}</p>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="bg-white rounded-xl p-4 mb-6 border-2 shadow-md">
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input placeholder="Search by event, business, or location..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="pl-10 border-slate-200 h-11" />
</div>
<div className="flex items-center gap-2 bg-gradient-to-r from-blue-50 to-indigo-50 p-2 rounded-xl border-2 border-blue-200">
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size="lg"
onClick={() => setViewMode("table")}
className={`${viewMode === "table" ? "bg-blue-600 text-white hover:bg-blue-700 shadow-lg" : "hover:bg-white/50"} h-11 px-6 font-semibold cursor-pointer`}
>
<List className="w-5 h-5 mr-2" />
Table View
</Button>
<Button
variant={viewMode === "scheduler" ? "default" : "ghost"}
size="lg"
onClick={() => setViewMode("scheduler")}
className={`${viewMode === "scheduler" ? "bg-blue-600 text-white hover:bg-blue-700 shadow-lg" : "hover:bg-white/50"} h-11 px-6 font-semibold cursor-pointer`}
>
<LayoutGrid className="w-5 h-5 mr-2" />
Scheduler View
</Button>
</div>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
<TabsList className="bg-white border">
<TabsTrigger value="all">All ({getTabCount("all")})</TabsTrigger>
<TabsTrigger value="conflicts" className="data-[state=active]:bg-orange-500 data-[state=active]:text-white">
<AlertTriangle className="w-4 h-4 mr-2" />
Conflicts ({getTabCount("conflicts")})
</TabsTrigger>
<TabsTrigger value="upcoming">Upcoming ({getTabCount("upcoming")})</TabsTrigger>
<TabsTrigger value="active">Active ({getTabCount("active")})</TabsTrigger>
<TabsTrigger value="fully_staffed">Fully Staffed ({getTabCount("fully_staffed")})</TabsTrigger>
<TabsTrigger value="past">Past ({getTabCount("past")})</TabsTrigger>
<TabsTrigger value="draft">Draft ({getTabCount("draft")})</TabsTrigger>
</TabsList>
</Tabs>
{viewMode === "scheduler" && (
<>
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="w-5 h-5" />
Smart Assignment Logic
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="flex items-center justify-between">
<Label htmlFor="skill" className="flex items-center gap-2"><Users className="w-4 h-4" />Skill Match</Label>
<Switch id="skill" checked={assignmentOptions.prioritizeSkill} onCheckedChange={(checked) => setAssignmentOptions(prev => ({...prev, prioritizeSkill: checked}))} />
</div>
<div className="flex items-center justify-between">
<Label htmlFor="reliability" className="flex items-center gap-2"><TrendingUp className="w-4 h-4" />Reliability</Label>
<Switch id="reliability" checked={assignmentOptions.prioritizeReliability} onCheckedChange={(checked) => setAssignmentOptions(prev => ({...prev, prioritizeReliability: checked}))} />
</div>
<div className="flex items-center justify-between">
<Label htmlFor="fatigue" className="flex items-center gap-2"><Clock className="w-4 h-4" />Low Fatigue</Label>
<Switch id="fatigue" checked={assignmentOptions.prioritizeFatigue} onCheckedChange={(checked) => setAssignmentOptions(prev => ({...prev, prioritizeFatigue: checked}))} />
</div>
<div className="flex items-center justify-between">
<Label htmlFor="compliance" className="flex items-center gap-2"><Shield className="w-4 h-4" />Compliance</Label>
<Switch id="compliance" checked={assignmentOptions.prioritizeCompliance} onCheckedChange={(checked) => setAssignmentOptions(prev => ({...prev, prioritizeCompliance: checked}))} />
</div>
</div>
</CardContent>
</Card>
<Tabs defaultValue="scheduler" className="space-y-4">
<TabsList>
<TabsTrigger value="scheduler">Drag & Drop Scheduler</TabsTrigger>
<TabsTrigger value="automations">Active Automations</TabsTrigger>
</TabsList>
<TabsContent value="scheduler">
<DragDropScheduler events={upcomingEvents} staff={unassignedStaff} onAssign={handleAssign} onUnassign={handleUnassign} />
</TabsContent>
<TabsContent value="automations">
<Card>
<CardHeader>
<CardTitle>Active Automations</CardTitle>
<p className="text-sm text-slate-500">Background processes reducing manual work by 85%</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center"><Zap className="w-5 h-5 text-green-600" /></div>
<div><p className="font-semibold text-sm">Auto-Fill Open Shifts</p><p className="text-xs text-slate-500">Automatically assigns best-fit staff to unfilled roles</p></div>
</div>
<Badge className="bg-green-100 text-green-700">Active</Badge>
</div>
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center"><Shield className="w-5 h-5 text-blue-600" /></div>
<div><p className="font-semibold text-sm">Auto-Confirm Workers</p><p className="text-xs text-slate-500">Confirms staff 24 hours before shift</p></div>
</div>
<Badge className="bg-blue-100 text-blue-700">Active</Badge>
</div>
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center"><Clock className="w-5 h-5 text-purple-600" /></div>
<div><p className="font-semibold text-sm">Auto-Send Reminders</p><p className="text-xs text-slate-500">Sends reminders 2 hours before shift start</p></div>
</div>
<Badge className="bg-purple-100 text-purple-700">Active</Badge>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<AutomationEngine />
</>
)}
{viewMode === "table" && (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50 border-b">
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide h-10">BUSINESS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">HUB</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">EVENT</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">DATE & TIME</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">STATUS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">REQUESTED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ASSIGNED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">INVOICE</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredEvents.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center py-12 text-slate-500"><CalendarIcon className="w-12 h-12 mx-auto mb-3 text-slate-300" /><p className="font-medium">No events found</p></TableCell></TableRow>
) : (
filteredEvents.map((event) => {
const assignmentStatus = getAssignmentStatus(event);
const showAutoButton = assignmentStatus.status !== 'full' && event.status !== 'Canceled' && event.status !== 'Completed' && event.status !== 'Fully Staffed';
const hasConflicts = event.detected_conflicts && event.detected_conflicts.length > 0;
const eventTimes = getEventTimes(event);
const eventDate = safeParseDate(event.date);
const dayOfWeek = eventDate ? format(eventDate, 'EEEE') : '';
return (
<React.Fragment key={event.id}>
<TableRow className="hover:bg-slate-50 transition-colors border-b">
<TableCell className="py-3">
<p className="text-sm text-slate-700 font-medium">{event.business_name || "—"}</p>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center gap-1.5 text-sm text-slate-500">
<MapPin className="w-3.5 h-3.5" />
{event.hub || event.event_location || "Main Hub"}
</div>
</TableCell>
<TableCell className="py-3">
<p className="font-semibold text-slate-900 text-sm">{event.event_name}</p>
</TableCell>
<TableCell className="py-3">
<div className="space-y-0.5">
<p className="text-sm text-slate-900 font-semibold">{eventDate ? format(eventDate, 'MM.dd.yyyy') : '-'}</p>
<p className="text-xs text-slate-500">{dayOfWeek}</p>
<div className="flex items-center gap-1 text-xs text-slate-600 mt-1">
<Clock className="w-3 h-3" />
<span>{eventTimes.startTime} - {eventTimes.endTime}</span>
</div>
</div>
</TableCell>
<TableCell className="py-3">
{getStatusBadge(event, hasConflicts)}
</TableCell>
<TableCell className="text-center py-3">
<span className="font-semibold text-slate-700 text-sm">{event.requested || 0}</span>
</TableCell>
<TableCell className="text-center py-3">
<div className="flex flex-col items-center gap-1">
<Badge className={`${assignmentStatus.color} font-bold px-3 py-1 rounded-full text-xs`}>
{assignmentStatus.text}
</Badge>
<span className="text-[10px] text-slate-500 font-medium">{assignmentStatus.percent}</span>
</div>
</TableCell>
<TableCell className="text-center py-3">
{event.status === 'Completed' ? (
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl('Invoices'))}
className="hover:bg-slate-100 h-8 w-8 mx-auto"
title="View Invoice"
>
<FileText className="w-5 h-5 text-blue-600" />
</Button>
) : (
<span className="text-slate-300"></span>
)}
</TableCell>
<TableCell className="py-3">
<div className="flex items-center justify-center gap-1">
{showAutoButton && (
<Button
size="sm"
variant="ghost"
onClick={() => handleAutoAssignEvent(event)}
className="h-8 px-2 hover:bg-slate-100"
title="Smart Assign"
>
<UserCog className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EventDetail?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="View"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="Edit"
>
<Edit className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
{hasConflicts && activeTab === "conflicts" && (
<TableRow>
<TableCell colSpan={9} className="bg-orange-50/50 py-4">
<ConflictAlert conflicts={event.detected_conflicts} />
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})
)}
</TableBody>
</Table>
</div>
)}
</div>
<SmartAssignModal open={assignModal.open} onClose={() => setAssignModal({ open: false, event: null, shift: null, role: null })} event={assignModal.event} shift={assignModal.shift} role={assignModal.role} />
</div>
);
}

View File

@@ -0,0 +1,55 @@
import React, { useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Loader2 } from "lucide-react";
export default function Home() {
const navigate = useNavigate();
const { data: user, isLoading } = useQuery({
queryKey: ['current-user-redirect'],
queryFn: () => base44.auth.me(),
});
useEffect(() => {
if (user) {
const userRole = user.user_role || user.role || "admin";
// Route to appropriate dashboard based on role
const dashboardMap = {
admin: "Dashboard",
procurement: "ProcurementDashboard",
operator: "OperatorDashboard",
sector: "OperatorDashboard",
client: "ClientDashboard",
vendor: "VendorDashboard",
workforce: "WorkforceDashboard"
};
const targetDashboard = dashboardMap[userRole] || "Dashboard";
navigate(createPageUrl(targetDashboard), { replace: true });
}
}, [user, navigate]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
<div className="text-center">
<Loader2 className="w-12 h-12 text-[#0A39DF] animate-spin mx-auto mb-4" />
<p className="text-slate-600 font-medium">Loading your dashboard...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
<div className="text-center">
<Loader2 className="w-12 h-12 text-[#0A39DF] animate-spin mx-auto mb-4" />
<p className="text-slate-600 font-medium">Redirecting...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,380 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
Send, ArrowLeft, Mail, Building2, User, Percent,
CheckCircle2, Loader2, Info, Sparkles
} from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import PageHeader from "../components/common/PageHeader";
export default function InviteVendor() {
const { toast } = useToast();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { data: user } = useQuery({
queryKey: ['current-user-invite'],
queryFn: () => base44.auth.me(),
});
const [inviteData, setInviteData] = useState({
company_name: "",
primary_contact_name: "",
primary_contact_email: "",
vendor_admin_fee: 12,
notes: ""
});
const sendInviteMutation = useMutation({
mutationFn: async (data) => {
// Generate unique invite code
const inviteCode = `INV-${Math.floor(10000 + Math.random() * 90000)}`;
// Create invite record
const invite = await base44.entities.VendorInvite.create({
invite_code: inviteCode,
company_name: data.company_name,
primary_contact_name: data.primary_contact_name,
primary_contact_email: data.primary_contact_email,
vendor_admin_fee: parseFloat(data.vendor_admin_fee),
invited_by: user?.email || "admin",
invite_status: "pending",
invite_sent_date: new Date().toISOString(),
notes: data.notes
});
// Send email to vendor
const onboardingUrl = `${window.location.origin}${createPageUrl('SmartVendorOnboarding')}?invite=${inviteCode}`;
await base44.integrations.Core.SendEmail({
from_name: "KROW Platform",
to: data.primary_contact_email,
subject: `Welcome to KROW! You're Invited to Join Our Vendor Network`,
body: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background: linear-gradient(to bottom, #f8fafc, #e0f2fe); border-radius: 12px;">
<div style="text-align: center; padding: 20px; background: linear-gradient(to right, #0A39DF, #1C323E); border-radius: 12px 12px 0 0;">
<h1 style="color: white; margin: 0;">Welcome to KROW!</h1>
<p style="color: #e0f2fe; margin-top: 8px;">You've been invited to join our vendor network</p>
</div>
<div style="padding: 30px; background: white; border-radius: 0 0 12px 12px;">
<p style="color: #1C323E; font-size: 16px; line-height: 1.6;">
Hi ${data.primary_contact_name},
</p>
<p style="color: #475569; font-size: 14px; line-height: 1.6; margin-top: 16px;">
Great news! We'd like to invite <strong>${data.company_name}</strong> to join the KROW vendor network.
Our platform connects top-tier service providers with clients who need reliable, high-quality staffing solutions.
</p>
<div style="background: #f1f5f9; padding: 20px; border-radius: 8px; margin: 24px 0; border-left: 4px solid #0A39DF;">
<h3 style="color: #1C323E; margin: 0 0 12px 0; font-size: 16px;">Why Join KROW?</h3>
<ul style="color: #475569; margin: 0; padding-left: 20px; line-height: 1.8;">
<li>Access to vetted enterprise clients</li>
<li>Streamlined order management</li>
<li>Instant AI-powered rate proposals</li>
<li>Real-time market intelligence</li>
<li>Automated compliance tracking</li>
</ul>
</div>
<div style="text-align: center; margin: 32px 0;">
<a href="${onboardingUrl}"
style="display: inline-block; padding: 16px 32px; background: linear-gradient(to right, #0A39DF, #1C323E);
color: white; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 16px;
box-shadow: 0 4px 6px rgba(10, 57, 223, 0.3);">
Start Onboarding →
</a>
</div>
<div style="background: #fef3c7; padding: 16px; border-radius: 8px; margin-top: 24px; border: 1px solid #fbbf24;">
<p style="color: #92400e; font-size: 13px; margin: 0; line-height: 1.6;">
<strong>📋 What to Prepare:</strong><br>
• W-9 Tax Form<br>
• Certificate of Insurance (COI)<br>
• Secretary of State Certificate<br>
• List of positions you can provide<br>
• Your competitive rate proposals
</p>
</div>
<p style="color: #64748b; font-size: 13px; margin-top: 32px; padding-top: 20px; border-top: 1px solid #e2e8f0;">
Your invite code: <strong>${inviteCode}</strong><br>
Vendor fee: <strong>${data.vendor_admin_fee}%</strong><br>
Questions? Reply to this email or contact us at support@krow.com
</p>
<p style="color: #94a3b8; font-size: 12px; margin-top: 16px;">
This invitation was sent by ${user?.full_name || user?.email} from KROW.
</p>
</div>
</div>
`
});
return invite;
},
onSuccess: (invite) => {
queryClient.invalidateQueries(['vendor-invites']);
toast({
title: "Invite Sent Successfully!",
description: `${inviteData.company_name} will receive an email at ${inviteData.primary_contact_email}`,
});
// Reset form
setInviteData({
company_name: "",
primary_contact_name: "",
primary_contact_email: "",
vendor_admin_fee: 12,
notes: ""
});
},
onError: (error) => {
toast({
title: "Failed to Send Invite",
description: error.message,
variant: "destructive"
});
}
});
const handleSubmit = (e) => {
e.preventDefault();
if (!inviteData.company_name || !inviteData.primary_contact_email) {
toast({
title: "Missing Information",
description: "Please fill in company name and contact email",
variant: "destructive"
});
return;
}
sendInviteMutation.mutate(inviteData);
};
return (
<div className="p-4 md:p-8 bg-gradient-to-br from-slate-50 to-blue-50/30 min-h-screen">
<div className="max-w-3xl mx-auto">
<PageHeader
title="Invite Vendor to KROW"
subtitle="Send a personalized onboarding invitation"
actions={
<Button
variant="outline"
onClick={() => navigate(createPageUrl("VendorManagement"))}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Vendors
</Button>
}
/>
{/* Info Banner */}
<Card className="mb-6 border-blue-200 bg-blue-50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Sparkles className="w-5 h-5 text-[#0A39DF] mt-0.5" />
<div>
<h4 className="font-semibold text-[#1C323E] mb-1">Smart Vendor Onboarding</h4>
<p className="text-sm text-slate-600">
Send an invite with a custom vendor fee. The vendor will receive a welcome email
with an onboarding link powered by AI for document validation, rate proposals, and market intelligence.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Invite Form */}
<form onSubmit={handleSubmit}>
<Card className="border-2 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-r from-slate-50 to-blue-50 border-b">
<div className="flex items-center gap-3">
<Send className="w-6 h-6 text-[#0A39DF]" />
<div>
<CardTitle className="text-2xl text-[#1C323E]">Vendor Invitation Details</CardTitle>
<p className="text-sm text-slate-600 mt-1">Fill in the vendor information below</p>
</div>
</div>
</CardHeader>
<CardContent className="p-6 space-y-6">
{/* Company Name */}
<div>
<Label htmlFor="company_name" className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-[#0A39DF]" />
Company Name *
</Label>
<Input
id="company_name"
value={inviteData.company_name}
onChange={(e) => setInviteData(prev => ({ ...prev, company_name: e.target.value }))}
placeholder="ABC Staffing Solutions LLC"
className="mt-2"
required
/>
</div>
{/* Contact Information */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="contact_name" className="flex items-center gap-2">
<User className="w-4 h-4 text-[#0A39DF]" />
Primary Contact Name
</Label>
<Input
id="contact_name"
value={inviteData.primary_contact_name}
onChange={(e) => setInviteData(prev => ({ ...prev, primary_contact_name: e.target.value }))}
placeholder="John Smith"
className="mt-2"
/>
</div>
<div>
<Label htmlFor="contact_email" className="flex items-center gap-2">
<Mail className="w-4 h-4 text-[#0A39DF]" />
Contact Email *
</Label>
<Input
id="contact_email"
type="email"
value={inviteData.primary_contact_email}
onChange={(e) => setInviteData(prev => ({ ...prev, primary_contact_email: e.target.value }))}
placeholder="john@abcstaffing.com"
className="mt-2"
required
/>
</div>
</div>
{/* Vendor Fee */}
<div>
<Label htmlFor="vendor_admin_fee" className="flex items-center gap-2">
<Percent className="w-4 h-4 text-[#0A39DF]" />
Vendor Fee (%) *
<HoverCard>
<HoverCardTrigger>
<Info className="w-4 h-4 text-slate-400 cursor-help" />
</HoverCardTrigger>
<HoverCardContent className="w-80">
<div className="space-y-2">
<h4 className="font-semibold text-[#1C323E]">What is Vendor Fee?</h4>
<p className="text-sm text-slate-600">
The vendor fee is a percentage charged on top of the vendor's rate to cover
platform services, compliance, insurance, and payment processing.
</p>
<p className="text-sm text-slate-600">
Standard: <strong>12%</strong> • Can be adjusted based on vendor tier,
volume commitments, or special partnerships.
</p>
</div>
</HoverCardContent>
</HoverCard>
</Label>
<div className="flex items-center gap-3 mt-2">
<Input
id="vendor_admin_fee"
type="number"
step="0.1"
min="0"
max="100"
value={inviteData.vendor_admin_fee}
onChange={(e) => setInviteData(prev => ({ ...prev, vendor_admin_fee: e.target.value }))}
className="w-32"
required
/>
<span className="text-sm text-slate-600">
This fee will be automatically applied to all vendor rate proposals
</span>
</div>
</div>
{/* Internal Notes */}
<div>
<Label htmlFor="notes">Internal Notes (Optional)</Label>
<Textarea
id="notes"
value={inviteData.notes}
onChange={(e) => setInviteData(prev => ({ ...prev, notes: e.target.value }))}
placeholder="Add any internal notes about this vendor..."
className="mt-2"
rows={3}
/>
<p className="text-xs text-slate-500 mt-1">These notes are for internal use only and won't be shared with the vendor</p>
</div>
{/* Preview Card */}
<Card className="bg-gradient-to-br from-blue-50 to-white border-[#0A39DF]/30">
<CardContent className="p-4">
<h4 className="font-semibold text-[#1C323E] mb-3">Invitation Preview</h4>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-slate-600">Company:</span>
<span className="font-medium text-[#1C323E]">{inviteData.company_name || "—"}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-600">Contact:</span>
<span className="font-medium text-[#1C323E]">{inviteData.primary_contact_name || "—"}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-600">Email:</span>
<span className="font-medium text-[#1C323E]">{inviteData.primary_contact_email || "—"}</span>
</div>
<div className="flex items-center justify-between pt-2 border-t">
<span className="text-slate-600">Vendor Fee:</span>
<Badge className="bg-[#0A39DF] text-white">{inviteData.vendor_admin_fee}%</Badge>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
{/* Action Buttons */}
<div className="flex items-center justify-between mt-6">
<Button
type="button"
variant="outline"
onClick={() => navigate(createPageUrl("VendorManagement"))}
>
Cancel
</Button>
<Button
type="submit"
disabled={sendInviteMutation.isPending}
className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90"
>
{sendInviteMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending Invite...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Send Invitation
</>
)}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { base44 } from "@/api/base44Client";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import InvoiceDetailView from "../components/invoices/InvoiceDetailView";
import InvoiceExportPanel from "../components/invoices/InvoiceExportPanel";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
export default function InvoiceDetail() {
const navigate = useNavigate();
const urlParams = new URLSearchParams(window.location.search);
const invoiceId = urlParams.get('id');
const { data: user } = useQuery({
queryKey: ['current-user-invoice-detail'],
queryFn: () => base44.auth.me(),
});
const { data: invoices = [], isLoading } = useQuery({
queryKey: ['invoices'],
queryFn: () => base44.entities.Invoice.list(),
});
const { data: businesses = [] } = useQuery({
queryKey: ['businesses-for-invoice'],
queryFn: () => base44.entities.Business.list(),
});
const invoice = invoices.find(inv => inv.id === invoiceId);
const business = businesses.find(b =>
b.business_name === invoice?.business_name ||
b.business_name === invoice?.hub
);
const userRole = user?.user_role || user?.role;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="w-16 h-16 border-4 border-[#0A39DF] border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-600">Loading invoice...</p>
</div>
</div>
);
}
if (!invoice) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<p className="text-xl font-semibold text-slate-900 mb-4">Invoice not found</p>
<Button onClick={() => navigate(createPageUrl('Invoices'))}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Invoices
</Button>
</div>
</div>
);
}
return (
<>
<div className="fixed top-20 left-4 z-50 print:hidden">
<Button
variant="outline"
onClick={() => navigate(createPageUrl('Invoices'))}
className="bg-white shadow-lg"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</div>
<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

@@ -0,0 +1,869 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Plus, Trash2, Clock } from "lucide-react";
import { createPageUrl } from "@/utils";
import { useToast } from "@/components/ui/use-toast";
import { format, addDays } from "date-fns";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export default function InvoiceEditor() {
const navigate = useNavigate();
const { toast } = useToast();
const queryClient = useQueryClient();
const urlParams = new URLSearchParams(window.location.search);
const invoiceId = urlParams.get('id');
const isEdit = !!invoiceId;
const { data: user } = useQuery({
queryKey: ['current-user-invoice-editor'],
queryFn: () => base44.auth.me(),
});
const { data: invoices = [] } = useQuery({
queryKey: ['invoices'],
queryFn: () => base44.entities.Invoice.list(),
enabled: isEdit,
});
const { data: events = [] } = useQuery({
queryKey: ['events-for-invoice'],
queryFn: () => base44.entities.Event.list(),
});
const existingInvoice = invoices.find(inv => inv.id === invoiceId);
const [formData, setFormData] = useState({
invoice_number: existingInvoice?.invoice_number || `INV-G00G${Math.floor(Math.random() * 100000)}`,
event_id: existingInvoice?.event_id || "",
event_name: existingInvoice?.event_name || "",
invoice_date: existingInvoice?.issue_date || format(new Date(), 'yyyy-MM-dd'),
due_date: existingInvoice?.due_date || format(addDays(new Date(), 30), 'yyyy-MM-dd'),
payment_terms: existingInvoice?.payment_terms || "30",
hub: existingInvoice?.hub || "",
manager: existingInvoice?.manager_name || "",
vendor_id: existingInvoice?.vendor_id || "",
department: existingInvoice?.department || "",
po_reference: existingInvoice?.po_reference || "",
from_company: existingInvoice?.from_company || {
name: "Legendary Event Staffing",
address: "848 E Gish Rd Ste 1, San Jose, CA 95112",
phone: "(408) 936-0180",
email: "order@legendaryeventstaff.com"
},
to_company: existingInvoice?.to_company || {
name: "Thinkloops",
phone: "4086702861",
email: "mohsin@thikloops.com",
address: "Dublin St, San Francisco, CA 94112, USA",
manager_name: "Manager Name",
hub_name: "Hub Name",
vendor_id: "Vendor #"
},
staff_entries: existingInvoice?.roles?.[0]?.staff_entries || [],
charges: existingInvoice?.charges || [],
other_charges: existingInvoice?.other_charges || 0,
notes: existingInvoice?.notes || "",
});
const [timePickerOpen, setTimePickerOpen] = useState(null);
const [selectedTime, setSelectedTime] = useState({ hours: "09", minutes: "00", period: "AM" });
const saveMutation = useMutation({
mutationFn: async (data) => {
// Calculate totals
const staffTotal = data.staff_entries.reduce((sum, entry) => sum + (entry.total || 0), 0);
const chargesTotal = data.charges.reduce((sum, charge) => sum + ((charge.qty * charge.rate) || 0), 0);
const subtotal = staffTotal + chargesTotal;
const total = subtotal + (parseFloat(data.other_charges) || 0);
const roles = data.staff_entries.length > 0 ? [{
role_name: "Mixed",
staff_entries: data.staff_entries,
role_subtotal: staffTotal
}] : [];
const invoiceData = {
invoice_number: data.invoice_number,
event_id: data.event_id,
event_name: data.event_name,
event_date: data.invoice_date,
po_reference: data.po_reference,
from_company: data.from_company,
to_company: data.to_company,
business_name: data.to_company.name,
manager_name: data.manager,
vendor_name: data.from_company.name,
vendor_id: data.vendor_id,
hub: data.hub,
department: data.department,
cost_center: data.po_reference,
roles: roles,
charges: data.charges,
subtotal: subtotal,
other_charges: parseFloat(data.other_charges) || 0,
amount: total,
status: existingInvoice?.status || "Draft",
issue_date: data.invoice_date,
due_date: data.due_date,
payment_terms: data.payment_terms,
is_auto_generated: false,
notes: data.notes,
};
if (isEdit) {
return base44.entities.Invoice.update(invoiceId, invoiceData);
} else {
return base44.entities.Invoice.create(invoiceData);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices'] });
toast({
title: isEdit ? "✅ Invoice Updated" : "✅ Invoice Created",
description: isEdit ? "Invoice has been updated successfully" : "Invoice has been created successfully",
});
navigate(createPageUrl('Invoices'));
},
});
const handleAddStaffEntry = () => {
setFormData({
...formData,
staff_entries: [
...formData.staff_entries,
{
name: "Mohsin",
date: format(new Date(), 'MM/dd/yyyy'),
position: "Bartender",
check_in: "hh:mm",
lunch: 0,
check_out: "",
worked_hours: 0,
regular_hours: 0,
ot_hours: 0,
dt_hours: 0,
rate: 52.68,
regular_value: 0,
ot_value: 0,
dt_value: 0,
total: 0
}
]
});
};
const handleAddCharge = () => {
setFormData({
...formData,
charges: [
...formData.charges,
{
name: "Gas Compensation",
qty: 7.30,
rate: 0,
price: 0
}
]
});
};
const handleStaffChange = (index, field, value) => {
const newEntries = [...formData.staff_entries];
newEntries[index] = { ...newEntries[index], [field]: value };
// Recalculate totals if time-related fields change
if (['worked_hours', 'regular_hours', 'ot_hours', 'dt_hours', 'rate'].includes(field)) {
const entry = newEntries[index];
entry.regular_value = (entry.regular_hours || 0) * (entry.rate || 0);
entry.ot_value = (entry.ot_hours || 0) * (entry.rate || 0) * 1.5;
entry.dt_value = (entry.dt_hours || 0) * (entry.rate || 0) * 2;
entry.total = entry.regular_value + entry.ot_value + entry.dt_value;
}
setFormData({ ...formData, staff_entries: newEntries });
};
const handleChargeChange = (index, field, value) => {
const newCharges = [...formData.charges];
newCharges[index] = { ...newCharges[index], [field]: value };
if (['qty', 'rate'].includes(field)) {
newCharges[index].price = (newCharges[index].qty || 0) * (newCharges[index].rate || 0);
}
setFormData({ ...formData, charges: newCharges });
};
const handleRemoveStaff = (index) => {
setFormData({
...formData,
staff_entries: formData.staff_entries.filter((_, i) => i !== index)
});
};
const handleRemoveCharge = (index) => {
setFormData({
...formData,
charges: formData.charges.filter((_, i) => i !== index)
});
};
const handleTimeSelect = (entryIndex, field) => {
const timeString = `${selectedTime.hours}:${selectedTime.minutes} ${selectedTime.period}`;
handleStaffChange(entryIndex, field, timeString);
setTimePickerOpen(null);
};
const calculateTotals = () => {
const staffTotal = formData.staff_entries.reduce((sum, entry) => sum + (entry.total || 0), 0);
const chargesTotal = formData.charges.reduce((sum, charge) => sum + (charge.price || 0), 0);
const subtotal = staffTotal + chargesTotal;
const otherCharges = parseFloat(formData.other_charges) || 0;
const grandTotal = subtotal + otherCharges;
return { subtotal, otherCharges, grandTotal };
};
const totals = calculateTotals();
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-slate-50 p-6">
<div className="max-w-7xl mx-auto">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="outline" onClick={() => navigate(createPageUrl('Invoices'))} className="bg-white">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Invoices
</Button>
<div>
<h1 className="text-2xl font-bold text-slate-900">{isEdit ? 'Edit Invoice' : 'Create New Invoice'}</h1>
<p className="text-sm text-slate-600">Complete all invoice details below</p>
</div>
</div>
<Badge className="bg-blue-100 text-blue-700 text-sm px-3 py-1">
{existingInvoice?.status || "Draft"}
</Badge>
</div>
<Card className="p-8 bg-white shadow-lg border-blue-100">
{/* Invoice Details Header */}
<div className="flex items-start justify-between mb-6 pb-6 border-b border-blue-100">
<div className="flex-1">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">📄</span>
</div>
<div>
<h2 className="text-xl font-bold text-slate-900">Invoice Details</h2>
<p className="text-sm text-slate-500">Event: {formData.event_name || "Internal Support"}</p>
</div>
</div>
<div className="bg-gradient-to-r from-blue-50 to-blue-100 p-4 rounded-lg mb-4">
<div className="text-xs text-blue-600 font-semibold mb-1">Invoice Number</div>
<div className="font-bold text-2xl text-blue-900">{formData.invoice_number}</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<Label className="text-xs font-semibold text-slate-700">Invoice Date</Label>
<Input
type="date"
value={formData.invoice_date}
onChange={(e) => setFormData({ ...formData, invoice_date: e.target.value })}
className="mt-1 border-blue-200 focus:border-blue-500"
/>
</div>
<div>
<Label className="text-xs font-semibold text-slate-700">Due Date</Label>
<Input
type="date"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
className="mt-1 border-blue-200 focus:border-blue-500"
/>
</div>
</div>
<div className="mb-4">
<Label className="text-xs">Hub</Label>
<Input
value={formData.hub}
onChange={(e) => setFormData({ ...formData, hub: e.target.value })}
placeholder="Hub"
className="mt-1"
/>
</div>
<div className="mb-4">
<Label className="text-xs">Manager</Label>
<Input
value={formData.manager}
onChange={(e) => setFormData({ ...formData, manager: e.target.value })}
placeholder="Manager Name"
className="mt-1"
/>
</div>
<div>
<Label className="text-xs">Vendor #</Label>
<Input
value={formData.vendor_id}
onChange={(e) => setFormData({ ...formData, vendor_id: e.target.value })}
placeholder="Vendor #"
className="mt-1"
/>
</div>
</div>
<div className="flex-1 text-right">
<div className="mb-4">
<Label className="text-xs font-semibold text-slate-700 block mb-2">Payment Terms</Label>
<div className="flex gap-2 justify-end">
<Badge
className={`cursor-pointer transition-all ${formData.payment_terms === "30" ? "bg-blue-600 text-white" : "bg-white border-2 border-slate-200 text-slate-700 hover:border-blue-300"}`}
onClick={() => setFormData({ ...formData, payment_terms: "30", due_date: format(addDays(new Date(formData.invoice_date), 30), 'yyyy-MM-dd') })}
>
30 days
</Badge>
<Badge
className={`cursor-pointer transition-all ${formData.payment_terms === "45" ? "bg-blue-600 text-white" : "bg-white border-2 border-slate-200 text-slate-700 hover:border-blue-300"}`}
onClick={() => setFormData({ ...formData, payment_terms: "45", due_date: format(addDays(new Date(formData.invoice_date), 45), 'yyyy-MM-dd') })}
>
45 days
</Badge>
<Badge
className={`cursor-pointer transition-all ${formData.payment_terms === "60" ? "bg-blue-600 text-white" : "bg-white border-2 border-slate-200 text-slate-700 hover:border-blue-300"}`}
onClick={() => setFormData({ ...formData, payment_terms: "60", due_date: format(addDays(new Date(formData.invoice_date), 60), 'yyyy-MM-dd') })}
>
60 days
</Badge>
</div>
</div>
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500">Department:</span>
<Input
value={formData.department}
onChange={(e) => setFormData({ ...formData, department: e.target.value })}
placeholder="INV-G00G20242"
className="h-8 w-48"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500">PO#:</span>
<Input
value={formData.po_reference}
onChange={(e) => setFormData({ ...formData, po_reference: e.target.value })}
placeholder="INV-G00G20242"
className="h-8 w-48"
/>
</div>
</div>
</div>
</div>
{/* From and To */}
<div className="grid grid-cols-2 gap-6 mb-6">
<div className="bg-gradient-to-br from-blue-50 to-blue-100 p-5 rounded-xl border border-blue-200">
<h3 className="font-bold mb-4 flex items-center gap-2 text-blue-900">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white text-sm font-bold shadow-md">F</div>
From (Vendor):
</h3>
<div className="space-y-2 text-sm">
<Input
value={formData.from_company.name}
onChange={(e) => setFormData({
...formData,
from_company: { ...formData.from_company, name: e.target.value }
})}
className="font-semibold mb-2"
/>
<Input
value={formData.from_company.address}
onChange={(e) => setFormData({
...formData,
from_company: { ...formData.from_company, address: e.target.value }
})}
className="text-sm"
/>
<Input
value={formData.from_company.phone}
onChange={(e) => setFormData({
...formData,
from_company: { ...formData.from_company, phone: e.target.value }
})}
className="text-sm"
/>
<Input
value={formData.from_company.email}
onChange={(e) => setFormData({
...formData,
from_company: { ...formData.from_company, email: e.target.value }
})}
className="text-sm"
/>
</div>
</div>
<div className="bg-gradient-to-br from-slate-50 to-slate-100 p-5 rounded-xl border border-slate-200">
<h3 className="font-bold mb-4 flex items-center gap-2 text-slate-900">
<div className="w-8 h-8 bg-slate-600 rounded-lg flex items-center justify-center text-white text-sm font-bold shadow-md">T</div>
To (Client):
</h3>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-slate-500 w-32">Company:</span>
<Input
value={formData.to_company.name}
onChange={(e) => setFormData({
...formData,
to_company: { ...formData.to_company, name: e.target.value }
})}
className="flex-1"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500 w-32">Phone:</span>
<Input
value={formData.to_company.phone}
onChange={(e) => setFormData({
...formData,
to_company: { ...formData.to_company, phone: e.target.value }
})}
className="flex-1"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500 w-32">Manager Name:</span>
<Input
value={formData.to_company.manager_name}
onChange={(e) => setFormData({
...formData,
to_company: { ...formData.to_company, manager_name: e.target.value }
})}
className="flex-1"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500 w-32">Email:</span>
<Input
value={formData.to_company.email}
onChange={(e) => setFormData({
...formData,
to_company: { ...formData.to_company, email: e.target.value }
})}
className="flex-1"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500 w-32">Hub Name:</span>
<Input
value={formData.to_company.hub_name}
onChange={(e) => setFormData({
...formData,
to_company: { ...formData.to_company, hub_name: e.target.value }
})}
className="flex-1"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500 w-32">Address:</span>
<Input
value={formData.to_company.address}
onChange={(e) => setFormData({
...formData,
to_company: { ...formData.to_company, address: e.target.value }
})}
className="flex-1"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500 w-32">Vendor #:</span>
<Input
value={formData.to_company.vendor_id}
onChange={(e) => setFormData({
...formData,
to_company: { ...formData.to_company, vendor_id: e.target.value }
})}
className="flex-1"
/>
</div>
</div>
</div>
</div>
{/* Staff Table */}
<div className="mb-6">
<div className="flex items-center justify-between mb-4 p-4 bg-gradient-to-r from-blue-50 to-blue-100 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">👥</span>
</div>
<div>
<h3 className="font-bold text-blue-900">Staff Entries</h3>
<p className="text-xs text-blue-700">{formData.staff_entries.length} entries</p>
</div>
</div>
<Button size="sm" onClick={handleAddStaffEntry} className="bg-blue-600 hover:bg-blue-700 text-white shadow-md">
<Plus className="w-4 h-4 mr-1" />
Add Staff Entry
</Button>
</div>
<div className="overflow-x-auto border rounded-lg">
<table className="w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="p-2 text-left">#</th>
<th className="p-2 text-left">Name</th>
<th className="p-2 text-left">ClockIn</th>
<th className="p-2 text-left">Lunch</th>
<th className="p-2 text-left">Checkout</th>
<th className="p-2 text-left">Worked H</th>
<th className="p-2 text-left">Reg H</th>
<th className="p-2 text-left">OT Hours</th>
<th className="p-2 text-left">DT Hours</th>
<th className="p-2 text-left">Rate</th>
<th className="p-2 text-left">Reg Value</th>
<th className="p-2 text-left">OT Value</th>
<th className="p-2 text-left">DT Value</th>
<th className="p-2 text-left">Total</th>
<th className="p-2">Action</th>
</tr>
</thead>
<tbody>
{formData.staff_entries.map((entry, idx) => (
<tr key={idx} className="border-t hover:bg-slate-50">
<td className="p-2">{idx + 1}</td>
<td className="p-2">
<Input
value={entry.name}
onChange={(e) => handleStaffChange(idx, 'name', e.target.value)}
className="h-8 w-24"
/>
</td>
<td className="p-2">
<Popover open={timePickerOpen === `checkin-${idx}`} onOpenChange={(open) => setTimePickerOpen(open ? `checkin-${idx}` : null)}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 w-24 justify-start font-normal">
<Clock className="w-3 h-3 mr-1" />
{entry.check_in}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-3">
<div className="space-y-2">
<div className="flex gap-2">
<Input
type="number"
min="01"
max="12"
value={selectedTime.hours}
onChange={(e) => setSelectedTime({ ...selectedTime, hours: e.target.value.padStart(2, '0') })}
className="w-16"
placeholder="HH"
/>
<span className="text-2xl">:</span>
<Input
type="number"
min="00"
max="59"
value={selectedTime.minutes}
onChange={(e) => setSelectedTime({ ...selectedTime, minutes: e.target.value.padStart(2, '0') })}
className="w-16"
placeholder="MM"
/>
<Select value={selectedTime.period} onValueChange={(val) => setSelectedTime({ ...selectedTime, period: val })}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AM">AM</SelectItem>
<SelectItem value="PM">PM</SelectItem>
</SelectContent>
</Select>
</div>
<Button size="sm" onClick={() => handleTimeSelect(idx, 'check_in')} className="w-full">
Set Time
</Button>
</div>
</PopoverContent>
</Popover>
</td>
<td className="p-2">
<Input
type="number"
value={entry.lunch}
onChange={(e) => handleStaffChange(idx, 'lunch', parseFloat(e.target.value))}
className="h-8 w-16"
/>
</td>
<td className="p-2">
<Popover open={timePickerOpen === `checkout-${idx}`} onOpenChange={(open) => setTimePickerOpen(open ? `checkout-${idx}` : null)}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 w-24 justify-start font-normal">
<Clock className="w-3 h-3 mr-1" />
{entry.check_out || "hh:mm"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-3">
<div className="space-y-2">
<div className="flex gap-2">
<Input
type="number"
min="01"
max="12"
value={selectedTime.hours}
onChange={(e) => setSelectedTime({ ...selectedTime, hours: e.target.value.padStart(2, '0') })}
className="w-16"
placeholder="HH"
/>
<span className="text-2xl">:</span>
<Input
type="number"
min="00"
max="59"
value={selectedTime.minutes}
onChange={(e) => setSelectedTime({ ...selectedTime, minutes: e.target.value.padStart(2, '0') })}
className="w-16"
placeholder="MM"
/>
<Select value={selectedTime.period} onValueChange={(val) => setSelectedTime({ ...selectedTime, period: val })}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AM">AM</SelectItem>
<SelectItem value="PM">PM</SelectItem>
</SelectContent>
</Select>
</div>
<Button size="sm" onClick={() => handleTimeSelect(idx, 'check_out')} className="w-full">
Set Time
</Button>
</div>
</PopoverContent>
</Popover>
</td>
<td className="p-2">
<Input
type="number"
step="0.1"
value={entry.worked_hours}
onChange={(e) => handleStaffChange(idx, 'worked_hours', parseFloat(e.target.value))}
className="h-8 w-16"
/>
</td>
<td className="p-2">
<Input
type="number"
step="0.1"
value={entry.regular_hours}
onChange={(e) => handleStaffChange(idx, 'regular_hours', parseFloat(e.target.value))}
className="h-8 w-16"
/>
</td>
<td className="p-2">
<Input
type="number"
step="0.1"
value={entry.ot_hours}
onChange={(e) => handleStaffChange(idx, 'ot_hours', parseFloat(e.target.value))}
className="h-8 w-16"
/>
</td>
<td className="p-2">
<Input
type="number"
step="0.1"
value={entry.dt_hours}
onChange={(e) => handleStaffChange(idx, 'dt_hours', parseFloat(e.target.value))}
className="h-8 w-16"
/>
</td>
<td className="p-2">
<Input
type="number"
step="0.01"
value={entry.rate}
onChange={(e) => handleStaffChange(idx, 'rate', parseFloat(e.target.value))}
className="h-8 w-20"
/>
</td>
<td className="p-2 text-right">${entry.regular_value?.toFixed(2) || "0.00"}</td>
<td className="p-2 text-right">${entry.ot_value?.toFixed(2) || "0.00"}</td>
<td className="p-2 text-right">${entry.dt_value?.toFixed(2) || "0.00"}</td>
<td className="p-2 text-right font-semibold">${entry.total?.toFixed(2) || "0.00"}</td>
<td className="p-2 text-center">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveStaff(idx)}
className="h-8 w-8 p-0 text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Charges */}
<div className="mb-6">
<div className="flex items-center justify-between mb-4 p-4 bg-gradient-to-r from-green-50 to-emerald-100 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-emerald-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">💰</span>
</div>
<div>
<h3 className="font-bold text-emerald-900">Additional Charges</h3>
<p className="text-xs text-emerald-700">{formData.charges.length} charges</p>
</div>
</div>
<Button size="sm" onClick={handleAddCharge} className="bg-emerald-600 hover:bg-emerald-700 text-white shadow-md">
<Plus className="w-4 h-4 mr-1" />
Add Charge
</Button>
</div>
<div className="overflow-x-auto border rounded-lg">
<table className="w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="p-2 text-left">#</th>
<th className="p-2 text-left">Name</th>
<th className="p-2 text-left">QTY</th>
<th className="p-2 text-left">Rate</th>
<th className="p-2 text-left">Price</th>
<th className="p-2">Actions</th>
</tr>
</thead>
<tbody>
{formData.charges.map((charge, idx) => (
<tr key={idx} className="border-t hover:bg-slate-50">
<td className="p-2">{idx + 1}</td>
<td className="p-2">
<Input
value={charge.name}
onChange={(e) => handleChargeChange(idx, 'name', e.target.value)}
className="h-8"
/>
</td>
<td className="p-2">
<Input
type="number"
step="0.01"
value={charge.qty}
onChange={(e) => handleChargeChange(idx, 'qty', parseFloat(e.target.value))}
className="h-8 w-20"
/>
</td>
<td className="p-2">
<Input
type="number"
step="0.01"
value={charge.rate}
onChange={(e) => handleChargeChange(idx, 'rate', parseFloat(e.target.value))}
className="h-8 w-20"
/>
</td>
<td className="p-2">${charge.price?.toFixed(2) || "0.00"}</td>
<td className="p-2 text-center">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveCharge(idx)}
className="h-8 w-8 p-0 text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Totals */}
<div className="flex justify-end mb-6">
<div className="w-96 bg-gradient-to-br from-blue-50 to-blue-100 p-6 rounded-xl border-2 border-blue-200 shadow-lg">
<div className="space-y-4">
<div className="flex justify-between text-sm">
<span className="text-slate-600">Sub total:</span>
<span className="font-semibold text-slate-900">${totals.subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-slate-600">Other charges:</span>
<Input
type="number"
step="0.01"
value={formData.other_charges}
onChange={(e) => setFormData({ ...formData, other_charges: e.target.value })}
className="h-9 w-32 text-right border-blue-300 focus:border-blue-500 bg-white"
/>
</div>
<div className="flex justify-between text-xl font-bold pt-4 border-t-2 border-blue-300">
<span className="text-blue-900">Grand total:</span>
<span className="text-blue-900">${totals.grandTotal.toFixed(2)}</span>
</div>
</div>
</div>
</div>
{/* Notes */}
<div className="mb-6">
<Label className="mb-2 block">Notes</Label>
<Textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Enter your notes here..."
rows={3}
/>
</div>
{/* Actions */}
<div className="flex justify-between items-center pt-6 border-t-2 border-blue-100">
<Button variant="outline" onClick={() => navigate(createPageUrl('Invoices'))} className="border-slate-300">
Cancel
</Button>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => saveMutation.mutate({ ...formData, status: "Draft" })}
disabled={saveMutation.isPending}
className="border-blue-300 text-blue-700 hover:bg-blue-50"
>
Save as Draft
</Button>
<Button
onClick={() => saveMutation.mutate(formData)}
disabled={saveMutation.isPending}
className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold px-8 shadow-lg"
>
{saveMutation.isPending ? "Saving..." : isEdit ? "Update Invoice" : "Create Invoice"}
</Button>
</div>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,546 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { FileText, Plus, Search, Eye, AlertTriangle, CheckCircle, Clock, DollarSign, Edit, TrendingUp, TrendingDown, Calendar, ArrowUpRight, Sparkles, BarChart3, PieChart, MapPin, User } from "lucide-react";
import { format, parseISO, isPast } from "date-fns";
import PageHeader from "../components/common/PageHeader";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import AutoInvoiceGenerator from "../components/invoices/AutoInvoiceGenerator";
import CreateInvoiceModal from "../components/invoices/CreateInvoiceModal";
const statusColors = {
'Draft': 'bg-slate-100 text-slate-600 font-medium',
'Open': 'bg-blue-100 text-blue-700 font-medium',
'Pending Review': 'bg-blue-100 text-blue-700 font-medium',
'Confirmed': 'bg-amber-100 text-amber-700 font-medium',
'Approved': 'bg-emerald-100 text-emerald-700 font-medium',
'Disputed': 'bg-red-100 text-red-700 font-medium',
'Under Review': 'bg-orange-100 text-orange-700 font-medium',
'Resolved': 'bg-cyan-100 text-cyan-700 font-medium',
'Overdue': 'bg-red-100 text-red-700 font-medium',
'Paid': 'bg-emerald-100 text-emerald-700 font-medium',
'Reconciled': 'bg-purple-100 text-purple-700 font-medium',
'Cancelled': 'bg-slate-100 text-slate-600 font-medium',
};
export default function Invoices() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
const [showCreateModal, setShowCreateModal] = useState(false);
const { data: user } = useQuery({
queryKey: ['current-user-invoices'],
queryFn: () => base44.auth.me(),
});
const { data: invoices = [], isLoading } = useQuery({
queryKey: ['invoices'],
queryFn: () => base44.entities.Invoice.list('-issue_date'),
initialData: [],
});
const userRole = user?.user_role || user?.role;
// Auto-mark overdue invoices
React.useEffect(() => {
invoices.forEach(async (invoice) => {
if (invoice.status === "Approved" && isPast(parseISO(invoice.due_date))) {
try {
await base44.entities.Invoice.update(invoice.id, { status: "Overdue" });
} catch (error) {
console.error('Failed to mark invoice as overdue:', error);
}
}
});
}, [invoices]);
// Filter invoices based on user role
const visibleInvoices = React.useMemo(() => {
if (userRole === "client") {
return invoices.filter(inv =>
inv.business_name === user?.company_name ||
inv.manager_name === user?.full_name ||
inv.created_by === user?.email
);
}
if (userRole === "vendor") {
return invoices.filter(inv =>
inv.vendor_name === user?.company_name ||
inv.vendor_id === user?.vendor_id
);
}
return invoices;
}, [invoices, userRole, user]);
const getFilteredInvoices = () => {
let filtered = visibleInvoices;
if (activeTab !== "all") {
const statusMap = {
'pending': 'Pending Review',
'approved': 'Approved',
'disputed': 'Disputed',
'overdue': 'Overdue',
'paid': 'Paid',
'reconciled': 'Reconciled',
};
filtered = filtered.filter(inv => inv.status === statusMap[activeTab]);
}
if (searchTerm) {
filtered = filtered.filter(inv =>
inv.invoice_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
inv.business_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
inv.manager_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
inv.event_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return filtered;
};
const filteredInvoices = getFilteredInvoices();
const getStatusCount = (status) => {
if (status === "all") return visibleInvoices.length;
return visibleInvoices.filter(inv => inv.status === status).length;
};
const getTotalAmount = (status) => {
const filtered = status === "all"
? visibleInvoices
: visibleInvoices.filter(inv => inv.status === status);
return filtered.reduce((sum, inv) => sum + (inv.amount || 0), 0);
};
const metrics = {
all: getTotalAmount("all"),
pending: getTotalAmount("Pending Review"),
approved: getTotalAmount("Approved"),
disputed: getTotalAmount("Disputed"),
overdue: getTotalAmount("Overdue"),
paid: getTotalAmount("Paid"),
outstanding: getTotalAmount("Pending Review") + getTotalAmount("Approved") + getTotalAmount("Overdue"),
};
// Smart Insights
const insights = React.useMemo(() => {
const currentMonth = visibleInvoices.filter(inv => {
const issueDate = parseISO(inv.issue_date);
const now = new Date();
return issueDate.getMonth() === now.getMonth() && issueDate.getFullYear() === now.getFullYear();
});
const lastMonth = visibleInvoices.filter(inv => {
const issueDate = parseISO(inv.issue_date);
const now = new Date();
const lastMonthDate = new Date(now.getFullYear(), now.getMonth() - 1);
return issueDate.getMonth() === lastMonthDate.getMonth() && issueDate.getFullYear() === lastMonthDate.getFullYear();
});
const currentTotal = currentMonth.reduce((sum, inv) => sum + (inv.amount || 0), 0);
const lastTotal = lastMonth.reduce((sum, inv) => sum + (inv.amount || 0), 0);
const percentChange = lastTotal > 0 ? ((currentTotal - lastTotal) / lastTotal * 100).toFixed(1) : 0;
const avgPaymentTime = visibleInvoices
.filter(inv => inv.status === "Paid" && inv.paid_date && inv.issue_date)
.map(inv => {
const days = Math.floor((parseISO(inv.paid_date) - parseISO(inv.issue_date)) / (1000 * 60 * 60 * 24));
return days;
});
const avgDays = avgPaymentTime.length > 0 ? Math.round(avgPaymentTime.reduce((a, b) => a + b, 0) / avgPaymentTime.length) : 0;
const onTimePayments = visibleInvoices.filter(inv =>
inv.status === "Paid" && inv.paid_date && inv.due_date && parseISO(inv.paid_date) <= parseISO(inv.due_date)
).length;
const totalPaid = visibleInvoices.filter(inv => inv.status === "Paid").length;
const onTimeRate = totalPaid > 0 ? ((onTimePayments / totalPaid) * 100).toFixed(0) : 0;
const topClient = Object.entries(
visibleInvoices.reduce((acc, inv) => {
const client = inv.business_name || "Unknown";
acc[client] = (acc[client] || 0) + (inv.amount || 0);
return acc;
}, {})
).sort((a, b) => b[1] - a[1])[0];
// For clients: calculate best hub by reconciliation rate
const bestHub = userRole === "client" ? (() => {
const hubStats = visibleInvoices.reduce((acc, inv) => {
const hub = inv.hub || "Unknown";
if (!acc[hub]) {
acc[hub] = { total: 0, reconciled: 0, paid: 0 };
}
acc[hub].total++;
if (inv.status === "Reconciled") acc[hub].reconciled++;
if (inv.status === "Paid" || inv.status === "Reconciled") acc[hub].paid++;
return acc;
}, {});
const sortedHubs = Object.entries(hubStats)
.map(([hub, stats]) => ({
hub,
rate: stats.total > 0 ? ((stats.paid / stats.total) * 100).toFixed(0) : 0,
total: stats.total
}))
.sort((a, b) => b.rate - a.rate);
return sortedHubs[0] || null;
})() : null;
return {
percentChange,
isGrowth: percentChange > 0,
avgDays,
onTimeRate,
topClient: topClient ? { name: topClient[0], amount: topClient[1] } : null,
bestHub,
currentMonthCount: currentMonth.length,
currentTotal,
};
}, [visibleInvoices, userRole]);
return (
<>
<AutoInvoiceGenerator />
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-[1600px] mx-auto">
<PageHeader
title="Invoices"
subtitle={`${filteredInvoices.length} invoices • $${metrics.all.toLocaleString()} total`}
actions={
userRole === "vendor" && (
<Button onClick={() => setShowCreateModal(true)} className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
<Plus className="w-5 h-5 mr-2" />
Create Invoice
</Button>
)
}
/>
{/* Alert Banners */}
{metrics.disputed > 0 && (
<div className="mb-6 p-4 bg-red-50 border-l-4 border-red-500 rounded-lg flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-red-600" />
<div>
<p className="font-semibold text-red-900">Disputed Invoices Require Attention</p>
<p className="text-sm text-red-700">{getStatusCount("Disputed")} invoices are currently disputed</p>
</div>
</div>
)}
{metrics.overdue > 0 && userRole === "client" && (
<div className="mb-6 p-4 bg-amber-50 border-l-4 border-amber-500 rounded-lg flex items-center gap-3">
<Clock className="w-5 h-5 text-amber-600" />
<div>
<p className="font-semibold text-amber-900">Overdue Payments</p>
<p className="text-sm text-amber-700">${metrics.overdue.toLocaleString()} in overdue invoices</p>
</div>
</div>
)}
{/* Status Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
<TabsList className="bg-slate-100 border border-slate-200 h-auto p-1.5 flex-wrap gap-1">
<TabsTrigger
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
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
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
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
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
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
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>
</TabsList>
</Tabs>
{/* Metric Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="border-0 bg-blue-50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center gap-4">
<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-white" />
</div>
<div>
<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-blue-700">${metrics.all.toLocaleString()}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 bg-amber-50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-amber-500 rounded-xl flex items-center justify-center flex-shrink-0">
<DollarSign className="w-6 h-6 text-white" />
</div>
<div>
<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-700">${metrics.outstanding.toLocaleString()}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 bg-red-50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center gap-4">
<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-white" />
</div>
<div>
<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-700">${metrics.disputed.toLocaleString()}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 bg-emerald-50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center gap-4">
<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-white" />
</div>
<div>
<p className="text-xs text-emerald-600 uppercase tracking-wider font-semibold mb-0.5">Paid</p>
<p className="text-2xl font-bold text-emerald-700">${metrics.paid.toLocaleString()}</p>
</div>
</div>
</CardContent>
</Card>
</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 */}
<div className="bg-white rounded-lg p-4 mb-6 border border-slate-200">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
placeholder="Search by invoice number, client, event..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
{/* Invoices Table */}
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50">
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Invoice #</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Hub</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Event</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Manager</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Date & Time</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Amount</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Status</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredInvoices.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-slate-500">
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p className="font-medium">No invoices found</p>
</TableCell>
</TableRow>
) : (
filteredInvoices.map((invoice) => {
const invoiceDate = parseISO(invoice.issue_date);
const dayOfWeek = format(invoiceDate, 'EEEE');
const dateFormatted = format(invoiceDate, 'MM.dd.yy');
return (
<TableRow key={invoice.id} className="hover:bg-slate-50 transition-all border-b border-slate-100">
<TableCell className="font-bold text-slate-900">{invoice.invoice_number}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-purple-600" />
<span className="text-slate-900 font-medium">{invoice.hub || "—"}</span>
</div>
</TableCell>
<TableCell className="text-slate-900 font-medium">{invoice.event_name}</TableCell>
<TableCell>
<div className="flex items-center 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
variant="ghost"
size="sm"
onClick={() => navigate(createPageUrl(`InvoiceDetail?id=${invoice.id}`))}
className="font-semibold hover:bg-blue-50 hover:text-[#0A39DF]"
>
<Eye className="w-4 h-4 mr-2" />
View
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</div>
<CreateInvoiceModal
open={showCreateModal}
onClose={() => setShowCreateModal(false)}
/>
</>
);
}

View File

@@ -0,0 +1,520 @@
import React from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import {
Users, LayoutDashboard, UserPlus, Calendar, Briefcase, FileText,
DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare,
Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff,
RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical,
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap, ArrowLeft
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetTrigger,
} from "@/components/ui/sheet";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import ChatBubble from "@/components/chat/ChatBubble";
import RoleSwitcher from "@/components/dev/RoleSwitcher";
import NotificationPanel from "@/components/notifications/NotificationPanel";
import { NotificationEngine } from "@/components/notifications/NotificationEngine";
import { Toaster } from "@/components/ui/toaster";
// Navigation items for each role
const roleNavigationMap = {
admin: [
{ title: "Home", url: createPageUrl("Dashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Schedule", url: createPageUrl("Schedule"), icon: Calendar },
{ title: "Staff Availability", url: createPageUrl("StaffAvailability"), icon: Users },
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Onboarding", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign },
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
{ title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "User Management", url: createPageUrl("UserManagement"), icon: Users },
{ title: "Permissions", url: createPageUrl("Permissions"), icon: Shield },
{ title: "Settings", url: createPageUrl("Settings"), icon: SettingsIcon },
{ title: "Activity Log", url: createPageUrl("ActivityLog"), icon: Activity },
],
procurement: [
{ title: "Home", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Compliance", url: createPageUrl("WorkforceCompliance"), icon: Shield },
{ title: "Rate Matrix", url: createPageUrl("VendorRateCard"), icon: DollarSign },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
],
operator: [
{ title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
],
sector: [
{ title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
],
client: [
{ title: "Home", url: createPageUrl("ClientDashboard"), icon: LayoutDashboard },
{ title: "My Orders", url: createPageUrl("ClientOrders"), icon: Clipboard },
{ title: "New Order", url: createPageUrl("CreateEvent"), icon: UserPlus },
{ title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store },
{ title: "Compare Rates", url: createPageUrl("VendorRateCard"), icon: DollarSign },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Support", url: createPageUrl("Support"), icon: HelpCircle },
],
vendor: [
{ title: "Home", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
{ title: "Schedule", url: createPageUrl("Schedule"), icon: Calendar },
{ title: "Staff Availability", url: createPageUrl("StaffAvailability"), icon: Users },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "Team", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Compliance", url: createPageUrl("VendorCompliance"), icon: Shield },
{ title: "Communications", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Leads", url: createPageUrl("Business"), icon: UserCheck },
{ title: "Business", url: createPageUrl("Business"), icon: Briefcase },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Audit Trail", url: createPageUrl("ActivityLog"), icon: Activity },
{ title: "Performance", url: createPageUrl("VendorPerformance"), icon: TrendingUp },
],
workforce: [
{ title: "Home", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
{ title: "Shift Requests", url: createPageUrl("WorkerShiftProposals"), icon: Calendar },
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
{ title: "Earnings", url: createPageUrl("WorkforceEarnings"), icon: DollarSign },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Profile", url: createPageUrl("WorkforceProfile"), icon: Users },
],
};
const getRoleName = (role) => {
const names = {
admin: "KROW Admin",
procurement: "Procurement Manager",
operator: "Operator",
sector: "Sector Manager",
client: "Client",
vendor: "Vendor Partner",
workforce: "Staff Member"
};
return names[role] || "User";
};
const getRoleDescription = (role) => {
const descriptions = {
admin: "Platform Administrator",
procurement: "Connects Operators, Sectors & Vendors",
operator: "Manages Multiple Sectors",
sector: "Branch Location Manager",
client: "Service Requester",
vendor: "Workforce Provider",
workforce: "Service Professional"
};
return descriptions[role] || "";
};
const getDashboardUrl = (role) => {
const dashboardMap = {
admin: "Dashboard",
procurement: "ProcurementDashboard",
operator: "OperatorDashboard",
sector: "OperatorDashboard",
client: "ClientDashboard",
vendor: "VendorDashboard",
workforce: "WorkforceDashboard"
};
return createPageUrl(dashboardMap[role] || "Dashboard");
};
const getLayerName = (role) => {
const layers = {
admin: "KROW Admin",
procurement: "Procurement",
operator: "Operator",
sector: "Sector",
client: "Client",
vendor: "Vendor",
workforce: "Workforce"
};
return layers[role] || "User";
};
const getLayerColor = (role) => {
const colors = {
admin: "from-red-500 to-red-700",
procurement: "from-[#0A39DF] to-[#1C323E]",
operator: "from-emerald-500 to-emerald-700",
sector: "from-purple-500 to-purple-700",
client: "from-green-500 to-green-700",
vendor: "from-amber-500 to-amber-700",
workforce: "from-slate-500 to-slate-700"
};
return colors[role] || "from-blue-500 to-blue-700";
};
function NavigationMenu({ location, userRole, closeSheet }) {
const navigationItems = roleNavigationMap[userRole] || roleNavigationMap.admin;
return (
<nav className="space-y-1">
<div className="px-4 py-2 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Main Menu
</div>
{navigationItems.map((item) => {
const isActive = location.pathname === item.url;
return (
<Link
key={item.title}
to={item.url}
onClick={closeSheet}
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg transition-all duration-200 ${
isActive
? 'bg-[#0A39DF] text-white shadow-md font-medium'
: 'text-slate-600 hover:bg-slate-100 hover:text-[#1C323E]'
}`}
>
<item.icon className="w-5 h-5" />
<span className="text-sm">{item.title}</span>
</Link>
);
})}
</nav>
);
}
export default function Layout({ children }) {
const location = useLocation();
const navigate = useNavigate();
const [showNotifications, setShowNotifications] = React.useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
const { data: user } = useQuery({
queryKey: ['current-user-layout'],
queryFn: () => base44.auth.me(),
});
const sampleAvatar = "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=400&fit=crop";
const userAvatar = user?.profile_picture || sampleAvatar;
const { data: unreadCount = 0 } = useQuery({
queryKey: ['unread-notifications', user?.id],
queryFn: async () => {
if (!user?.id) return 0;
const notifications = await base44.entities.ActivityLog.filter({
userId: user?.id,
isRead: false
});
return notifications.length;
},
enabled: !!user?.id,
initialData: 0,
refetchInterval: 10000,
});
const userRole = user?.user_role || user?.role || "admin";
const userName = user?.full_name || user?.email || "User";
const userInitial = userName.charAt(0).toUpperCase();
const handleLogout = () => {
base44.auth.logout();
};
const handleRefresh = () => {
window.location.reload();
};
return (
<div className="min-h-screen flex flex-col w-full bg-slate-50">
<style>{`
:root {
--primary: 10 57 223;
--primary-foreground: 255 255 255;
--secondary: 200 219 220;
--accent: 28 50 62;
--muted: 241 245 249;
}
.rdp * { border-color: transparent !important; }
.rdp-day { font-size: 0.875rem !important; min-width: 36px !important; height: 36px !important; border-radius: 50% !important; transition: all 0.2s ease !important; font-weight: 500 !important; position: relative !important; }
.rdp-day button { width: 100% !important; height: 100% !important; border-radius: 50% !important; background-color: transparent !important; }
.rdp-day_range_start, .rdp-day_range_start > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
.rdp-day_range_end, .rdp-day_range_end > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
.rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end), .rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end) > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
.rdp-day_selected, .rdp-day_selected > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
.rdp-day_range_middle, .rdp-day_range_middle > button { background-color: #dbeafe !important; background: #dbeafe !important; color: #2563eb !important; font-weight: 600 !important; border-radius: 0 !important; box-shadow: none !important; }
.rdp-day_range_start.rdp-day_range_end, .rdp-day_range_start.rdp-day_range_end > button { border-radius: 50% !important; background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; }
.rdp-day:hover:not(.rdp-day_selected):not(.rdp-day_disabled):not(.rdp-day_range_start):not(.rdp-day_range_end):not(.rdp-day_range_middle) > button { background-color: #eff6ff !important; background: #eff6ff !important; color: #2563eb !important; border-radius: 50% !important; }
.rdp-day_today:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::after { content: '' !important; position: absolute !important; bottom: 4px !important; left: 50% !important; transform: translateX(-50%) !important; width: 4px !important; height: 4px !important; background-color: #ec4899 !important; border-radius: 50% !important; z-index: 10 !important; }
.rdp-day_today.rdp-day_selected, .rdp-day_today.rdp-day_range_start, .rdp-day_today.rdp-day_range_end { color: white !important; }
.rdp-day_today.rdp-day_selected > button, .rdp-day_today.rdp-day_range_start > button, .rdp-day_today.rdp-day_range_end > button { color: white !important; }
.rdp-day_outside, .rdp-day_outside > button { color: #cbd5e1 !important; opacity: 0.5 !important; }
.rdp-day_disabled, .rdp-day_disabled > button { opacity: 0.3 !important; cursor: not-allowed !important; }
.rdp-day_selected, .rdp-day_range_start, .rdp-day_range_end, .rdp-day_range_middle { opacity: 1 !important; visibility: visible !important; z-index: 5 !important; }
.rdp-day.has-events:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::before { content: '' !important; position: absolute !important; top: 4px !important; right: 4px !important; width: 4px !important; height: 4px !important; background-color: #2563eb !important; border-radius: 50% !important; }
.rdp-day_selected.has-events::before, .rdp-day_range_start.has-events::before, .rdp-day_range_end.has-events::before { background-color: white !important; }
.rdp-day_range_middle.has-events::before { background-color: #2563eb !important; }
.rdp-head_cell { color: #64748b !important; font-weight: 600 !important; font-size: 0.75rem !important; text-transform: uppercase !important; padding: 8px 0 !important; }
.rdp-caption_label { font-size: 1rem !important; font-weight: 700 !important; color: #0f172a !important; }
.rdp-nav_button { width: 32px !important; height: 32px !important; border-radius: 6px !important; transition: all 0.2s ease !important; }
.rdp-nav_button:hover { background-color: #eff6ff !important; color: #2563eb !important; }
.rdp-months { gap: 2rem !important; }
.rdp-month { padding: 0.75rem !important; }
.rdp-table { border-spacing: 0 !important; margin-top: 1rem !important; }
.rdp-cell { padding: 2px !important; }
.rdp-day[style*="background"] { background: transparent !important; }
`}</style>
<header className="bg-white border-b border-slate-200 shadow-sm sticky top-0 z-30">
<div className="px-4 md:px-6 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-4 flex-1">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
className="hover:bg-slate-100"
title="Go back"
>
<ArrowLeft className="w-5 h-5 text-slate-600" />
</Button>
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden hover:bg-slate-100">
<Menu className="w-5 h-5 text-[#1C323E]" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64 p-0 bg-white border-slate-200">
<div className="border-b border-slate-200 p-6">
<Link to={getDashboardUrl(userRole)} className="flex items-center gap-3 mb-4" onClick={() => setMobileMenuOpen(false)}>
<div className="w-8 h-8 flex items-center justify-center">
<img src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png" alt="KROW Logo" className="w-full h-full object-contain" />
</div>
<h2 className="font-bold text-[#1C323E]">KROW</h2>
</Link>
<div className="flex items-center gap-3 bg-slate-50 p-3 rounded-lg">
<Avatar className="w-10 h-10">
<AvatarImage src={userAvatar} alt={userName} />
<AvatarFallback className="bg-[#0A39DF] text-white font-bold">{userInitial}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-semibold text-[#1C323E] text-sm truncate">{userName}</p>
<p className="text-xs text-slate-500 truncate">{getRoleName(userRole)}</p>
</div>
</div>
</div>
<div className="p-3">
<NavigationMenu location={location} userRole={userRole} closeSheet={() => setMobileMenuOpen(false)} />
</div>
<div className="p-3 border-t border-slate-200">
<Button variant="ghost" className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {handleLogout(); setMobileMenuOpen(false);}}>
<LogOut className="w-4 h-4 mr-2" />Logout
</Button>
</div>
</SheetContent>
</Sheet>
<Link to={getDashboardUrl(userRole)} className="flex items-center gap-2 hover:opacity-80 transition-opacity">
<div className="w-8 h-8 flex items-center justify-center">
<img src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png" alt="KROW Logo" className="w-full h-full object-contain" />
</div>
<div className="hidden sm:block">
<h1 className="text-base font-bold text-[#1C323E]">KROW Workforce Control Tower</h1>
</div>
</Link>
<div className="hidden md:flex flex-1 max-w-xl">
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<input type="text" placeholder="Find employees, menu items, settings, and more..." className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#0A39DF] focus:border-transparent" />
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={handleRefresh} className="flex items-center gap-2 px-3 py-2 text-slate-500 hover:text-[#0A39DF] hover:bg-blue-50 rounded-lg transition-all group" title="Unpublished changes - Click to refresh">
<CloudOff className="w-5 h-5 group-hover:animate-pulse" />
<span className="hidden lg:inline text-sm font-medium">Unpublished changes</span>
</button>
<Button variant="ghost" size="icon" className="md:hidden hover:bg-slate-100" title="Search">
<Search className="w-5 h-5 text-slate-600" />
</Button>
<button onClick={() => setShowNotifications(true)} className="relative p-2 hover:bg-slate-100 rounded-lg transition-colors" title="Notifications">
<Bell className="w-5 h-5 text-slate-600" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
<Link to={getDashboardUrl(userRole)} title="Home">
<Button variant="ghost" size="icon" className="hover:bg-slate-100">
<Home className="w-5 h-5 text-slate-600" />
</Button>
</Link>
<Link to={createPageUrl("Messages")} title="Messages">
<Button variant="ghost" size="icon" className="hover:bg-slate-100">
<MessageSquare className="w-5 h-5 text-slate-600" />
</Button>
</Link>
<Link to={createPageUrl("Support")} title="Help & Support">
<Button variant="ghost" size="icon" className="hover:bg-slate-100">
<HelpCircle className="w-5 h-5 text-slate-600" />
</Button>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="hover:bg-slate-100" title="More options">
<MoreVertical className="w-5 h-5 text-slate-600" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("NotificationSettings")}>
<Bell className="w-4 h-4 mr-2" />Notification Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Settings")}>
<SettingsIcon className="w-4 h-4 mr-2" />Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Reports")}>
<FileText className="w-4 h-4 mr-2" />Reports
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("ActivityLog")}>
<Activity className="w-4 h-4 mr-2" />Activity Log
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-red-600">
<LogOut className="w-4 h-4 mr-2" />Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 hover:bg-slate-100 rounded-lg p-1.5 transition-colors" title={`${userName} - ${getRoleName(userRole)}`}>
<Avatar className="w-8 h-8">
<AvatarImage src={userAvatar} alt={userName} />
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white font-bold text-sm">{userInitial}</AvatarFallback>
</Avatar>
<span className="hidden lg:block text-sm font-medium text-slate-700">{userName.split(' ')[0]}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div>
<p className="font-semibold">{userName}</p>
<p className="text-xs text-slate-500">{user?.email}</p>
<p className="text-xs text-slate-500 mt-1">{getRoleName(userRole)}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => window.location.href = getDashboardUrl(userRole)}>
<Home className="w-4 h-4 mr-2" />Dashboard
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("WorkforceProfile")}>
<User className="w-4 h-4 mr-2" />My Profile
</DropdownMenuItem>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
<div className="flex flex-1 overflow-hidden">
<aside className="hidden lg:flex lg:flex-col w-64 bg-white border-r border-slate-200 shadow-sm overflow-y-auto">
<div className="p-3">
<NavigationMenu location={location} userRole={userRole} closeSheet={() => {}} />
</div>
</aside>
<main className="flex-1 overflow-auto pb-16">
{children}
</main>
</div>
<div className="fixed bottom-0 left-0 right-0 z-20 bg-white border-t-2 border-slate-200 shadow-lg">
<div className="px-4 py-2 flex items-center justify-center gap-3">
<span className="text-xs text-slate-500 font-medium">Current:</span>
<div className={`px-4 py-1.5 rounded-full bg-gradient-to-r ${getLayerColor(userRole)} text-white font-bold text-sm shadow-md`}>
{getLayerName(userRole)}
</div>
</div>
</div>
<NotificationPanel isOpen={showNotifications} onClose={() => setShowNotifications(false)} />
<NotificationEngine />
<ChatBubble />
<RoleSwitcher />
<Toaster />
</div>
);
}

View File

@@ -0,0 +1,86 @@
import React, { useState } from "react";
import { useNavigate, Link } from "react-router-dom";
import { signInWithEmailAndPassword } from "firebase/auth";
import { auth } from "@/firebase";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2 } from "lucide-react";
export default function Login() {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const handleLogin = async (e) => {
e.preventDefault();
setError(null);
if (!email || !password) {
setError("Email and password are required.");
return;
}
setLoading(true);
try {
await signInWithEmailAndPassword(auth, email, password);
navigate("/");
} catch (error) {
setError("Invalid credentials. Please try again.");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
<Card className="w-[350px]">
<CardHeader>
<CardTitle>Login</CardTitle>
<CardDescription>Enter your credentials to access your account.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin}>
<div className="grid w-full items-center gap-4">
<div className="flex flex-col space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="flex flex-col space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
</div>
</form>
</CardContent>
<CardFooter className="flex-col">
<Button onClick={handleLogin} disabled={loading} className="w-full">
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Login"}
</Button>
<p className="mt-4 text-sm text-slate-600">
Don't have an account?{" "}
<Link to="/register" className="text-blue-600 hover:underline">
Register
</Link>
</p>
</CardFooter>
</Card>
</div>
);
}

View File

@@ -0,0 +1,524 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MessageSquare, Plus, Users } from "lucide-react";
import ConversationList from "../components/messaging/ConversationList";
import MessageThread from "../components/messaging/MessageThread";
import MessageInput from "../components/messaging/MessageInput";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import PageHeader from "../components/common/PageHeader";
export default function Messages() {
const [selectedConversation, setSelectedConversation] = useState(null);
const [activeTab, setActiveTab] = useState("all");
const [showNewConversation, setShowNewConversation] = useState(false);
const [conversationMode, setConversationMode] = useState("individual");
const [newConvData, setNewConvData] = useState({
type: "client-vendor",
subject: "",
participant_id: "",
participant_name: "",
participant_role: "client",
group_type: "all-active-staff",
event_id: ""
});
const { data: user } = useQuery({
queryKey: ['current-user'],
queryFn: () => base44.auth.me(),
});
const { data: staff } = useQuery({
queryKey: ['staff-list'],
queryFn: () => base44.entities.Staff.list(),
initialData: [],
});
const { data: businesses } = useQuery({
queryKey: ['businesses-list'],
queryFn: () => base44.entities.Business.list(),
initialData: [],
});
const { data: events } = useQuery({
queryKey: ['events-list'],
queryFn: () => base44.entities.Event.list('-date'),
initialData: [],
});
const { data: conversations, refetch: refetchConversations } = useQuery({
queryKey: ['conversations'],
queryFn: () => base44.entities.Conversation.list('-last_message_at'),
initialData: [],
});
const { data: messages, refetch: refetchMessages } = useQuery({
queryKey: ['messages', selectedConversation?.id],
queryFn: () => base44.entities.Message.filter({ conversation_id: selectedConversation?.id }),
initialData: [],
enabled: !!selectedConversation?.id
});
const filteredConversations = conversations.filter(conv => {
if (activeTab === "all") return conv.status === "active";
if (activeTab === "groups") return conv.is_group && conv.status === "active";
return conv.conversation_type === activeTab && conv.status === "active";
});
const getAvailableParticipants = () => {
const convType = newConvData.type;
if (convType === "client-vendor" || convType === "client-admin") {
return businesses.map(b => ({
id: b.id,
name: b.business_name,
role: "client",
contact: b.contact_name
}));
} else if (convType === "staff-client" || convType === "staff-admin") {
return staff.map(s => ({
id: s.id,
name: s.employee_name,
role: "staff",
position: s.position
}));
} else if (convType === "vendor-admin") {
return businesses.map(b => ({
id: b.id,
name: b.business_name,
role: "vendor",
contact: b.contact_name
}));
}
return [];
};
const handleParticipantSelect = (participantId) => {
const participants = getAvailableParticipants();
const selected = participants.find(p => p.id === participantId);
if (selected) {
setNewConvData({
...newConvData,
participant_id: selected.id,
participant_name: selected.name,
participant_role: selected.role
});
}
};
const getGroupParticipants = () => {
if (newConvData.group_type === "all-active-staff") {
return staff.map(s => ({
id: s.id,
name: s.employee_name,
role: "staff"
}));
} else if (newConvData.group_type === "event-staff" && newConvData.event_id) {
const event = events.find(e => e.id === newConvData.event_id);
if (event && event.assigned_staff) {
return event.assigned_staff.map(s => ({
id: s.staff_id,
name: s.staff_name,
role: "staff"
}));
}
}
return [];
};
const handleCreateConversation = async () => {
if (!newConvData.subject) return;
let participants = [];
let conversationType = "";
let groupName = "";
let isGroup = false;
let relatedTo = null;
let relatedType = null;
if (conversationMode === "individual") {
if (!newConvData.participant_id) return;
participants = [
{ id: user?.id, name: user?.full_name || user?.email, role: "admin" },
{
id: newConvData.participant_id,
name: newConvData.participant_name,
role: newConvData.participant_role
}
];
conversationType = newConvData.type;
} else {
const groupParticipants = getGroupParticipants();
if (groupParticipants.length === 0) return;
participants = [
{ id: user?.id, name: user?.full_name || user?.email, role: "admin" },
...groupParticipants
];
isGroup = true;
if (newConvData.group_type === "all-active-staff") {
conversationType = "group-staff";
groupName = "All Active Staff";
} else if (newConvData.group_type === "event-staff") {
conversationType = "group-event-staff";
const event = events.find(e => e.id === newConvData.event_id);
groupName = event ? `${event.event_name} Team` : "Event Team";
relatedTo = newConvData.event_id;
relatedType = "event";
}
}
const newConv = await base44.entities.Conversation.create({
participants,
conversation_type: conversationType,
is_group: isGroup,
group_name: groupName,
subject: newConvData.subject,
status: "active",
related_to: relatedTo,
related_type: relatedType
});
setShowNewConversation(false);
setConversationMode("individual");
setNewConvData({
type: "client-vendor",
subject: "",
participant_id: "",
participant_name: "",
participant_role: "client",
group_type: "all-active-staff",
event_id: ""
});
refetchConversations();
setSelectedConversation(newConv);
};
const availableParticipants = getAvailableParticipants();
const groupParticipants = conversationMode === "group" ? getGroupParticipants() : [];
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<PageHeader
title="Messages"
subtitle="Communicate with your team, vendors, and clients"
icon={MessageSquare}
>
<Button onClick={() => setShowNewConversation(true)} className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
<Plus className="w-4 h-4 mr-2" />
New Conversation
</Button>
</PageHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
<TabsList className="bg-white border border-slate-200">
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="groups">
<Users className="w-4 h-4 mr-2" />
Groups
</TabsTrigger>
<TabsTrigger value="client-vendor">Client Vendor</TabsTrigger>
<TabsTrigger value="staff-client">Staff Client</TabsTrigger>
<TabsTrigger value="staff-admin">Staff Admin</TabsTrigger>
</TabsList>
</Tabs>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-1 border-slate-200">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle className="text-base">Conversations</CardTitle>
</CardHeader>
<CardContent className="p-4 max-h-[600px] overflow-y-auto">
<ConversationList
conversations={filteredConversations}
selectedId={selectedConversation?.id}
onSelect={setSelectedConversation}
/>
</CardContent>
</Card>
<Card className="lg:col-span-2 border-slate-200">
{selectedConversation ? (
<>
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<div>
<div className="flex items-center gap-2 mb-1">
<CardTitle>{selectedConversation.subject}</CardTitle>
{selectedConversation.is_group && (
<Badge className="bg-purple-100 text-purple-700">
<Users className="w-3 h-3 mr-1" />
Group
</Badge>
)}
</div>
{selectedConversation.is_group ? (
<p className="text-sm text-slate-500 mt-1">
{selectedConversation.group_name} {selectedConversation.participants?.length || 0} members
</p>
) : (
<p className="text-sm text-slate-500 mt-1">
{selectedConversation.participants?.map(p => p.name).join(', ')}
</p>
)}
</div>
</CardHeader>
<CardContent className="p-0">
<MessageThread messages={messages} currentUserId={user?.id} />
<MessageInput
conversationId={selectedConversation.id}
currentUser={user}
onMessageSent={() => {
refetchMessages();
refetchConversations();
}}
/>
</CardContent>
</>
) : (
<div className="flex items-center justify-center h-full min-h-[400px]">
<div className="text-center">
<MessageSquare className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<p className="text-slate-500">Select a conversation to start messaging</p>
</div>
</div>
)}
</Card>
</div>
</div>
<Dialog open={showNewConversation} onOpenChange={setShowNewConversation}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label className="mb-3 block font-semibold">Conversation Mode</Label>
<div className="flex gap-3">
<button
onClick={() => setConversationMode("individual")}
className={`flex-1 flex items-center space-x-3 p-4 rounded-lg border-2 cursor-pointer transition-all ${
conversationMode === "individual"
? 'border-[#0A39DF] bg-blue-50'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
conversationMode === "individual" ? 'border-[#0A39DF]' : 'border-slate-300'
}`}>
{conversationMode === "individual" && (
<div className="w-2 h-2 rounded-full bg-[#0A39DF]" />
)}
</div>
<div className="flex-1 text-left">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
<span className="font-semibold">Individual</span>
</div>
<p className="text-xs text-slate-500 mt-1">One-on-one conversation</p>
</div>
</button>
<button
onClick={() => setConversationMode("group")}
className={`flex-1 flex items-center space-x-3 p-4 rounded-lg border-2 cursor-pointer transition-all ${
conversationMode === "group"
? 'border-[#0A39DF] bg-blue-50'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
conversationMode === "group" ? 'border-[#0A39DF]' : 'border-slate-300'
}`}>
{conversationMode === "group" && (
<div className="w-2 h-2 rounded-full bg-[#0A39DF]" />
)}
</div>
<div className="flex-1 text-left">
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span className="font-semibold">Group Chat</span>
</div>
<p className="text-xs text-slate-500 mt-1">Message multiple people</p>
</div>
</button>
</div>
</div>
{conversationMode === "individual" ? (
<>
<div>
<Label>Conversation Type</Label>
<Select
value={newConvData.type}
onValueChange={(v) => setNewConvData({
...newConvData,
type: v,
participant_id: "",
participant_name: ""
})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="client-vendor">Client Vendor</SelectItem>
<SelectItem value="staff-client">Staff Client</SelectItem>
<SelectItem value="staff-admin">Staff Admin</SelectItem>
<SelectItem value="vendor-admin">Vendor Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Select {newConvData.type.includes('client') ? 'Client' : newConvData.type.includes('staff') ? 'Staff' : 'Vendor'}</Label>
<Select
value={newConvData.participant_id}
onValueChange={handleParticipantSelect}
>
<SelectTrigger>
<SelectValue placeholder={`Choose a ${newConvData.type.includes('client') ? 'client' : newConvData.type.includes('staff') ? 'staff member' : 'vendor'}`} />
</SelectTrigger>
<SelectContent>
{availableParticipants.length > 0 ? (
availableParticipants.map((participant) => (
<SelectItem key={participant.id} value={participant.id}>
<div className="flex flex-col">
<span className="font-medium">{participant.name}</span>
{participant.position && (
<span className="text-xs text-slate-500">{participant.position}</span>
)}
{participant.contact && (
<span className="text-xs text-slate-500">Contact: {participant.contact}</span>
)}
</div>
</SelectItem>
))
) : (
<SelectItem value="none" disabled>
No {newConvData.type.includes('client') ? 'clients' : newConvData.type.includes('staff') ? 'staff' : 'vendors'} available
</SelectItem>
)}
</SelectContent>
</Select>
</div>
</>
) : (
<>
<div>
<Label>Group Type</Label>
<Select
value={newConvData.group_type}
onValueChange={(v) => setNewConvData({...newConvData, group_type: v, event_id: ""})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all-active-staff">
<div className="flex flex-col">
<span className="font-medium">All Active Staff</span>
<span className="text-xs text-slate-500">{staff.length} members</span>
</div>
</SelectItem>
<SelectItem value="event-staff">
<div className="flex flex-col">
<span className="font-medium">Event Staff Team</span>
<span className="text-xs text-slate-500">Select staff assigned to an event</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{newConvData.group_type === "event-staff" && (
<div>
<Label>Select Event</Label>
<Select
value={newConvData.event_id}
onValueChange={(v) => setNewConvData({...newConvData, event_id: v})}
>
<SelectTrigger>
<SelectValue placeholder="Choose an event" />
</SelectTrigger>
<SelectContent>
{events.filter(e => e.assigned_staff && e.assigned_staff.length > 0).map((event) => (
<SelectItem key={event.id} value={event.id}>
<div className="flex flex-col">
<span className="font-medium">{event.event_name}</span>
<span className="text-xs text-slate-500">
{event.assigned_staff?.length || 0} staff assigned
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{groupParticipants.length > 0 && (
<div className="bg-slate-50 p-4 rounded-lg">
<Label className="text-sm font-semibold mb-2 block">
Group Members ({groupParticipants.length})
</Label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{groupParticipants.map((p) => (
<Badge key={p.id} variant="outline" className="bg-white">
{p.name}
</Badge>
))}
</div>
</div>
)}
</>
)}
<div>
<Label>Subject</Label>
<Input
placeholder={conversationMode === "group" ? "e.g., Team Announcement" : "e.g., Order #12345 Discussion"}
value={newConvData.subject}
onChange={(e) => setNewConvData({...newConvData, subject: e.target.value})}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNewConversation(false)}>Cancel</Button>
<Button
onClick={handleCreateConversation}
className="bg-[#0A39DF]"
disabled={
!newConvData.subject ||
(conversationMode === "individual" && !newConvData.participant_id) ||
(conversationMode === "group" && groupParticipants.length === 0)
}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,271 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Bell, Mail, Calendar, Briefcase, AlertCircle, CheckCircle } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { Badge } from "@/components/ui/badge";
export default function NotificationSettings() {
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: currentUser } = useQuery({
queryKey: ['current-user-notification-settings'],
queryFn: () => base44.auth.me(),
});
const [preferences, setPreferences] = useState(
currentUser?.notification_preferences || {
email_notifications: true,
in_app_notifications: true,
shift_assignments: true,
shift_reminders: true,
shift_changes: true,
upcoming_events: true,
new_leads: true,
invoice_updates: true,
system_alerts: true,
}
);
const updatePreferencesMutation = useMutation({
mutationFn: (prefs) => base44.auth.updateMe({ notification_preferences: prefs }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['current-user-notification-settings'] });
toast({
title: "✅ Settings Updated",
description: "Your notification preferences have been saved",
});
},
onError: (error) => {
toast({
title: "❌ Update Failed",
description: error.message,
variant: "destructive",
});
},
});
const handleToggle = (key) => {
setPreferences(prev => ({ ...prev, [key]: !prev[key] }));
};
const handleSave = () => {
updatePreferencesMutation.mutate(preferences);
};
const userRole = currentUser?.role || currentUser?.user_role || 'admin';
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-4xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900">Notification Settings</h1>
<p className="text-sm text-slate-500 mt-1">
Configure how and when you receive notifications
</p>
</div>
<div className="space-y-4">
{/* Global Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="w-5 h-5" />
Global Notification Settings
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
<div className="flex items-center gap-3">
<Bell className="w-5 h-5 text-blue-600" />
<div>
<Label className="font-semibold">In-App Notifications</Label>
<p className="text-sm text-slate-500">Receive notifications in the app</p>
</div>
</div>
<Switch
checked={preferences.in_app_notifications}
onCheckedChange={() => handleToggle('in_app_notifications')}
/>
</div>
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
<div className="flex items-center gap-3">
<Mail className="w-5 h-5 text-purple-600" />
<div>
<Label className="font-semibold">Email Notifications</Label>
<p className="text-sm text-slate-500">Receive notifications via email</p>
</div>
</div>
<Switch
checked={preferences.email_notifications}
onCheckedChange={() => handleToggle('email_notifications')}
/>
</div>
</CardContent>
</Card>
{/* Staff/Workforce Notifications */}
{(userRole === 'workforce' || userRole === 'admin' || userRole === 'vendor') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="w-5 h-5" />
Shift Notifications
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
<div>
<Label className="font-semibold">Shift Assignments</Label>
<p className="text-sm text-slate-500">When you're assigned to a new shift</p>
</div>
<Switch
checked={preferences.shift_assignments}
onCheckedChange={() => handleToggle('shift_assignments')}
/>
</div>
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
<div>
<Label className="font-semibold">Shift Reminders</Label>
<p className="text-sm text-slate-500">24 hours before your shift starts</p>
</div>
<Switch
checked={preferences.shift_reminders}
onCheckedChange={() => handleToggle('shift_reminders')}
/>
</div>
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
<div>
<Label className="font-semibold">Shift Changes</Label>
<p className="text-sm text-slate-500">When shift details are modified</p>
</div>
<Switch
checked={preferences.shift_changes}
onCheckedChange={() => handleToggle('shift_changes')}
/>
</div>
</CardContent>
</Card>
)}
{/* Client Notifications */}
{(userRole === 'client' || userRole === 'admin') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Briefcase className="w-5 h-5" />
Event Notifications
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
<div>
<Label className="font-semibold">Upcoming Events</Label>
<p className="text-sm text-slate-500">Reminders 3 days before your event</p>
</div>
<Switch
checked={preferences.upcoming_events}
onCheckedChange={() => handleToggle('upcoming_events')}
/>
</div>
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
<div>
<Label className="font-semibold">Staff Updates</Label>
<p className="text-sm text-slate-500">When staff are assigned or changed</p>
</div>
<Switch
checked={preferences.shift_changes}
onCheckedChange={() => handleToggle('shift_changes')}
/>
</div>
</CardContent>
</Card>
)}
{/* Vendor Notifications */}
{(userRole === 'vendor' || userRole === 'admin') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Briefcase className="w-5 h-5" />
Business Notifications
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
<div>
<Label className="font-semibold">New Leads</Label>
<p className="text-sm text-slate-500">When new staffing opportunities are available</p>
</div>
<Switch
checked={preferences.new_leads}
onCheckedChange={() => handleToggle('new_leads')}
/>
</div>
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
<div>
<Label className="font-semibold">Invoice Updates</Label>
<p className="text-sm text-slate-500">Invoice status changes and payments</p>
</div>
<Switch
checked={preferences.invoice_updates}
onCheckedChange={() => handleToggle('invoice_updates')}
/>
</div>
</CardContent>
</Card>
)}
{/* System Notifications */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="w-5 h-5" />
System Notifications
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
<div>
<Label className="font-semibold">System Alerts</Label>
<p className="text-sm text-slate-500">Important platform updates and announcements</p>
</div>
<Switch
checked={preferences.system_alerts}
onCheckedChange={() => handleToggle('system_alerts')}
/>
</div>
</CardContent>
</Card>
{/* Save Button */}
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={() => setPreferences(currentUser?.notification_preferences || {})}
>
Reset
</Button>
<Button
onClick={handleSave}
disabled={updatePreferencesMutation.isPending}
className="bg-[#0A39DF]"
>
{updatePreferencesMutation.isPending ? "Saving..." : "Save Preferences"}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,507 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle2, UserPlus, User, Lock, Briefcase } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export default function Onboarding() {
const navigate = useNavigate();
const { toast } = useToast();
const urlParams = new URLSearchParams(window.location.search);
const inviteCode = urlParams.get('invite');
const [formData, setFormData] = useState({
first_name: "",
last_name: "",
email: "",
phone: "",
title: "",
department: "",
hub: "",
password: "",
confirmPassword: ""
});
// Fetch invite details if invite code exists
const { data: invite } = useQuery({
queryKey: ['team-invite', inviteCode],
queryFn: async () => {
const allInvites = await base44.entities.TeamMemberInvite.list();
const foundInvite = allInvites.find(inv => inv.invite_code === inviteCode && inv.invite_status === 'pending');
if (foundInvite) {
// Pre-fill form with invite data
const nameParts = (foundInvite.full_name || "").split(' ');
setFormData(prev => ({
...prev,
email: foundInvite.email,
first_name: nameParts[0] || "",
last_name: nameParts.slice(1).join(' ') || "",
phone: foundInvite.phone || "",
department: foundInvite.department || "",
hub: foundInvite.hub || "",
title: foundInvite.title || ""
}));
}
return foundInvite;
},
enabled: !!inviteCode,
});
// Fetch available hubs for the team
const { data: hubs = [] } = useQuery({
queryKey: ['team-hubs-onboarding', invite?.team_id],
queryFn: async () => {
if (!invite?.team_id) return [];
const allHubs = await base44.entities.TeamHub.list();
return allHubs.filter(h => h.team_id === invite.team_id && h.is_active);
},
enabled: !!invite?.team_id,
initialData: [],
});
// Fetch team to get departments
const { data: team } = useQuery({
queryKey: ['team-for-departments', invite?.team_id],
queryFn: async () => {
if (!invite?.team_id) return null;
const allTeams = await base44.entities.Team.list();
return allTeams.find(t => t.id === invite.team_id);
},
enabled: !!invite?.team_id,
});
// Get all unique departments from team and hubs
const availableDepartments = React.useMemo(() => {
const depts = new Set();
// Add team departments
if (team?.departments) {
team.departments.forEach(d => depts.add(d));
}
// Add hub departments
hubs.forEach(hub => {
if (hub.departments) {
hub.departments.forEach(dept => {
if (dept.department_name) {
depts.add(dept.department_name);
}
});
}
});
return Array.from(depts);
}, [team, hubs]);
const registerMutation = useMutation({
mutationFn: async (data) => {
if (!invite) {
throw new Error("Invalid invitation. Please contact your team administrator.");
}
// Check if invite was already accepted
if (invite.invite_status !== 'pending') {
throw new Error("This invitation has already been used. Please contact your team administrator for a new invitation.");
}
// Check for duplicate email in TeamMember
const allMembers = await base44.entities.TeamMember.list();
const existingMemberByEmail = allMembers.find(m =>
m.email?.toLowerCase() === data.email.toLowerCase() && m.team_id === invite.team_id
);
if (existingMemberByEmail) {
throw new Error(`A team member with email ${data.email} already exists in this team. Please contact your team administrator.`);
}
// Check for duplicate phone in TeamMember
if (data.phone) {
const existingMemberByPhone = allMembers.find(m =>
m.phone === data.phone && m.team_id === invite.team_id
);
if (existingMemberByPhone) {
throw new Error(`A team member with phone number ${data.phone} already exists in this team. Please contact your team administrator.`);
}
}
// Create team member record
const member = await base44.entities.TeamMember.create({
team_id: invite.team_id,
member_name: `${data.first_name} ${data.last_name}`.trim(),
email: data.email,
phone: data.phone,
title: data.title,
department: data.department,
hub: data.hub,
role: invite.role || "member",
is_active: true,
});
// Update invite status to accepted
await base44.entities.TeamMemberInvite.update(invite.id, {
invite_status: "accepted",
accepted_date: new Date().toISOString()
});
// 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: () => {
// Registration complete - user will see success message in UI
},
onError: (error) => {
toast({
title: "❌ Registration Failed",
description: error.message,
variant: "destructive",
});
},
});
const handleSubmit = async (e) => {
e.preventDefault();
// Validate all fields
if (!formData.first_name || !formData.last_name || !formData.email || !formData.phone) {
toast({
title: "Missing Information",
description: "Please fill in your name, email, and phone number",
variant: "destructive",
});
return;
}
if (!formData.title || !formData.department) {
toast({
title: "Missing Information",
description: "Please fill in your title and department",
variant: "destructive",
});
return;
}
if (!formData.password || formData.password !== formData.confirmPassword) {
toast({
title: "Password Mismatch",
description: "Passwords do not match",
variant: "destructive",
});
return;
}
if (formData.password.length < 6) {
toast({
title: "Password Too Short",
description: "Password must be at least 6 characters",
variant: "destructive",
});
return;
}
// Submit registration
registerMutation.mutate(formData);
};
if (!inviteCode || !invite) {
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">
<Card className="max-w-md w-full border-2 border-red-200">
<CardContent className="p-12 text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
<span className="text-4xl"></span>
</div>
<h2 className="text-2xl font-bold text-[#1C323E] mb-4">Invalid Invitation</h2>
<p className="text-slate-600 mb-6">
This invitation link is invalid or has expired. Please contact your team administrator for a new invitation.
</p>
<Button onClick={() => navigate(createPageUrl("Home"))} variant="outline">
Go to Home
</Button>
</CardContent>
</Card>
</div>
);
}
// Check if invite was already accepted
if (invite.invite_status === 'accepted') {
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">
<Card className="max-w-md w-full border-2 border-blue-200">
<CardContent className="p-12 text-center">
<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>
</div>
<h2 className="text-2xl font-bold text-[#1C323E] mb-4">Invitation Already Used</h2>
<p className="text-slate-600 mb-6">
This invitation has already been accepted. If you need access, please contact your team administrator.
</p>
<Button onClick={() => navigate(createPageUrl("Home"))} variant="outline">
Go to Home
</Button>
</CardContent>
</Card>
</div>
);
}
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="max-w-4xl w-full">
{/* Header */}
<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">
<UserPlus className="w-8 h-8 text-white" />
</div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-[#1C323E] to-[#0A39DF] bg-clip-text text-transparent mb-2">
Join {invite.team_name}
</h1>
{invite.hub && (
<div className="inline-block bg-blue-600 text-white px-6 py-2 rounded-full font-bold mb-3 shadow-lg">
📍 {invite.hub}
</div>
)}
<p className="text-slate-600">
You've been invited by {invite.invited_by} as a <strong>{invite.role}</strong>
{invite.department && <span> in <strong>{invite.department}</strong></span>}
</p>
</div>
<form onSubmit={handleSubmit}>
{registerMutation.isSuccess ? (
<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 {invite.team_name}! 🎉
</h2>
<p className="text-slate-600 mb-2">
Your registration has been completed successfully!
</p>
<div className="bg-blue-50 border border-blue-200 p-6 rounded-lg mb-8">
<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 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>
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="john@example.com"
className="mt-2"
disabled={!!invite}
/>
</div>
<div>
<Label htmlFor="phone">Phone Number *</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
placeholder="+1 (555) 123-4567"
className="mt-2"
/>
</div>
{/* Work Information */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-4 pb-2 border-b mt-4">
<Briefcase className="w-5 h-5 text-[#0A39DF]" />
<h3 className="font-bold text-[#1C323E]">Work Information</h3>
</div>
</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"
className="mt-2"
/>
</div>
<div>
<Label htmlFor="department">Department *</Label>
<Select value={formData.department} onValueChange={(value) => setFormData({ ...formData, department: value })}>
<SelectTrigger className="mt-2">
<SelectValue placeholder="Select department" />
</SelectTrigger>
<SelectContent>
{availableDepartments.length > 0 ? (
availableDepartments.map((dept) => (
<SelectItem key={dept} value={dept}>
{dept}
</SelectItem>
))
) : (
<SelectItem value="Operations">Operations</SelectItem>
)}
</SelectContent>
</Select>
</div>
{hubs.length > 0 && (
<div className="md:col-span-2">
<Label htmlFor="hub">Hub Location</Label>
<Select value={formData.hub} onValueChange={(value) => setFormData({ ...formData, hub: value })}>
<SelectTrigger className="mt-2">
<SelectValue placeholder="Select hub location" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null}>No Hub</SelectItem>
{hubs.map((hub) => (
<SelectItem key={hub.id} value={hub.hub_name}>
{hub.hub_name}
</SelectItem>
))}
</SelectContent>
</Select>
{formData.hub && (
<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>
<Button
type="submit"
disabled={registerMutation.isPending}
className="w-full mt-8 bg-blue-600 hover:bg-blue-700 text-white h-12 text-lg font-bold"
>
{registerMutation.isPending ? 'Creating Account...' : '🚀 Complete Registration'}
</Button>
</CardContent>
</Card>
)}
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,245 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { MapPin, TrendingUp, AlertTriangle, CheckCircle2, Clock, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, AreaChart, Area } from 'recharts';
import PageHeader from "../components/common/PageHeader";
const coverageData = [
{ hub: 'San Jose', coverage: 97, incidents: 0, satisfaction: 4.9 },
{ hub: 'San Francisco', coverage: 92, incidents: 1, satisfaction: 4.7 },
{ hub: 'Oakland', coverage: 89, incidents: 2, satisfaction: 4.5 },
{ hub: 'Sacramento', coverage: 95, incidents: 1, satisfaction: 4.8 },
];
const demandForecast = [
{ week: 'Week 1', predicted: 120, actual: 118 },
{ week: 'Week 2', predicted: 135, actual: 140 },
{ week: 'Week 3', predicted: 145, actual: 142 },
{ week: 'Week 4', predicted: 160, actual: null },
{ week: 'Week 5', predicted: 155, actual: null },
];
const incidents = [
{ id: 1, type: 'Late Arrival', hub: 'San Francisco', staff: 'John Doe', severity: 'low', time: '2 hours ago' },
{ id: 2, type: 'No Show', hub: 'Oakland', staff: 'Jane Smith', severity: 'high', time: '5 hours ago' },
];
const feedbackData = [
{ client: 'Tech Corp', rating: 5, comment: 'Excellent service, very professional staff', date: '2 days ago' },
{ client: 'Event Solutions', rating: 4, comment: 'Good overall, minor scheduling issues', date: '3 days ago' },
{ client: 'Premier Events', rating: 5, comment: 'Outstanding performance, will book again', date: '1 week ago' },
];
export default function OperatorDashboard() {
const navigate = useNavigate();
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<PageHeader
title="Operator & Sector Dashboard"
subtitle="Live coverage, demand forecasting, and incident tracking"
showUnpublished={true}
backTo={createPageUrl("Dashboard")}
backButtonLabel="Back to Dashboard"
/>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<Users className="w-8 h-8 text-[#0A39DF]" />
<Badge className="bg-green-100 text-green-700">+5%</Badge>
</div>
<p className="text-sm text-slate-500">Coverage Rate</p>
<p className="text-3xl font-bold text-[#1C323E]">94%</p>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<AlertTriangle className="w-8 h-8 text-yellow-600" />
<Badge className="bg-green-100 text-green-700">Low</Badge>
</div>
<p className="text-sm text-slate-500">Active Incidents</p>
<p className="text-3xl font-bold text-[#1C323E]">2</p>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<CheckCircle2 className="w-8 h-8 text-green-600" />
<Badge className="bg-blue-100 text-blue-700">4.8/5.0</Badge>
</div>
<p className="text-sm text-slate-500">Client Satisfaction</p>
<p className="text-3xl font-bold text-[#1C323E]">4.8</p>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<TrendingUp className="w-8 h-8 text-emerald-600" />
<Badge className="bg-emerald-100 text-emerald-700">91%</Badge>
</div>
<p className="text-sm text-slate-500">Forecast Accuracy</p>
<p className="text-3xl font-bold text-[#1C323E]">91%</p>
</CardContent>
</Card>
</div>
{/* Shift Coverage Heat Map */}
<Card className="mb-8 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-[#1C323E]">Live Shift Coverage Map</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{coverageData.map((hub, index) => (
<div key={index} className="p-6 rounded-xl border-2 border-slate-200 hover:border-[#0A39DF] hover:shadow-lg transition-all">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
hub.coverage >= 95 ? 'bg-green-100' :
hub.coverage >= 90 ? 'bg-blue-100' :
'bg-yellow-100'
}`}>
<MapPin className={`w-6 h-6 ${
hub.coverage >= 95 ? 'text-green-600' :
hub.coverage >= 90 ? 'text-blue-600' :
'text-yellow-600'
}`} />
</div>
<div>
<h4 className="font-bold text-[#1C323E]">{hub.hub}</h4>
<p className="text-sm text-slate-500">Coverage: {hub.coverage}%</p>
</div>
</div>
<Badge className={`${
hub.coverage >= 95 ? 'bg-green-100 text-green-700' :
hub.coverage >= 90 ? 'bg-blue-100 text-blue-700' :
'bg-yellow-100 text-yellow-700'
} text-lg px-3 py-1`}>
{hub.coverage}%
</Badge>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-slate-500">Incidents</p>
<p className="font-bold text-red-600">{hub.incidents}</p>
</div>
<div>
<p className="text-slate-500">Satisfaction</p>
<p className="font-bold text-emerald-600">{hub.satisfaction}/5.0</p>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
{/* Predictive Demand Forecast */}
<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-[#1C323E]">Predictive Demand Forecast</CardTitle>
</CardHeader>
<CardContent className="p-6">
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={demandForecast}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="week" />
<YAxis />
<Tooltip />
<Legend />
<Area type="monotone" dataKey="predicted" stackId="1" stroke="#0A39DF" fill="#0A39DF" fillOpacity={0.3} name="Predicted" />
<Area type="monotone" dataKey="actual" stackId="2" stroke="#10b981" fill="#10b981" fillOpacity={0.6} name="Actual" />
</AreaChart>
</ResponsiveContainer>
<p className="text-sm text-slate-500 mt-4">
Forecast accuracy: <span className="font-bold text-[#0A39DF]">91%</span> based on historical data
</p>
</CardContent>
</Card>
{/* Incident Feed */}
<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-[#1C323E]">Incident Feed</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{incidents.map((incident) => (
<div key={incident.id} className="p-4 rounded-lg border-2 border-slate-200 hover:border-red-300 transition-all">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<AlertTriangle className={`w-5 h-5 ${
incident.severity === 'high' ? 'text-red-600' :
incident.severity === 'medium' ? 'text-yellow-600' :
'text-blue-600'
}`} />
<h4 className="font-semibold text-[#1C323E]">{incident.type}</h4>
</div>
<Badge className={`${
incident.severity === 'high' ? 'bg-red-100 text-red-700' :
incident.severity === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-blue-100 text-blue-700'
}`}>
{incident.severity.toUpperCase()}
</Badge>
</div>
<div className="space-y-1 text-sm">
<p><span className="text-slate-500">Hub:</span> <span className="font-medium">{incident.hub}</span></p>
<p><span className="text-slate-500">Staff:</span> <span className="font-medium">{incident.staff}</span></p>
<p className="text-slate-400 flex items-center gap-1">
<Clock className="w-3 h-3" />
{incident.time}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Client Feedback */}
<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-[#1C323E]">Recent Client Feedback</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{feedbackData.map((feedback, index) => (
<div key={index} className="p-4 rounded-lg border border-slate-200 hover:border-[#0A39DF] hover:shadow-md transition-all">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-semibold text-[#1C323E]">{feedback.client}</h4>
<div className="flex items-center gap-1 mt-1">
{[...Array(5)].map((_, i) => (
<span key={i} className={i < feedback.rating ? "text-yellow-400" : "text-slate-300"}></span>
))}
</div>
</div>
<span className="text-xs text-slate-400">{feedback.date}</span>
</div>
<p className="text-sm text-slate-600">{feedback.comment}</p>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,645 @@
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Briefcase, Plus, Search, MapPin, DollarSign, Edit, Building2, TrendingUp, AlertTriangle, CheckCircle2, Users, Target, LayoutGrid, List } from "lucide-react";
import PageHeader from "../components/common/PageHeader";
export default function PartnerManagement() {
const [searchTerm, setSearchTerm] = useState("");
const [viewMode, setViewMode] = useState("grid"); // New state for view mode
const { data: partners = [], isLoading: partnersLoading } = useQuery({
queryKey: ['partners'],
queryFn: () => base44.entities.Partner.list('-created_date'),
initialData: [],
});
const { data: businesses = [], isLoading: businessesLoading } = useQuery({
queryKey: ['businesses'],
queryFn: () => base44.entities.Business.list('-created_date'),
initialData: [],
});
const { data: sectors = [] } = useQuery({
queryKey: ['sectors'],
queryFn: () => base44.entities.Sector.list('-created_date'),
initialData: [],
});
const { data: enterprises = [] } = useQuery({
queryKey: ['enterprises'],
queryFn: () => base44.entities.Enterprise.list('-created_date'),
initialData: [],
});
// Consolidate businesses by company name
const consolidatedBusinesses = useMemo(() => {
const grouped = {};
businesses.forEach(business => {
let companyName = business.business_name;
// Extract company name (remove hub suffix if present)
const dashIndex = companyName.indexOf(' - ');
if (dashIndex > 0) {
companyName = companyName.substring(0, dashIndex).trim();
}
if (!grouped[companyName]) {
grouped[companyName] = {
company_name: companyName,
partner_type: "Corporate",
hubs: [],
primary_contact: business.contact_name,
primary_email: business.email,
primary_phone: business.phone,
sector: business.sector || business.area || '',
total_hubs: 0,
company_logo: business.company_logo || null,
};
}
grouped[companyName].hubs.push({
id: business.id,
hub_name: business.business_name,
contact_name: business.contact_name,
email: business.email,
phone: business.phone,
address: business.address,
city: business.city,
area: business.area,
rate_group: business.rate_group,
company_logo: business.company_logo,
});
grouped[companyName].total_hubs++;
// Use the first hub's logo if available
if (business.company_logo && !grouped[companyName].company_logo) {
grouped[companyName].company_logo = business.company_logo;
}
});
return Object.values(grouped);
}, [businesses]);
// Operator coverage data
const operatorMetrics = {
totalCoverage: 94,
activeIncidents: 2,
clientSatisfaction: 4.8,
forecastAccuracy: 91
};
const hubCoverageData = [
{ hub: 'San Jose', enterprise: 'Compass', sector: 'Bon Appétit', coverage: 97, incidents: 0, satisfaction: 4.9, partners: 12 },
{ hub: 'San Francisco', enterprise: 'Compass', sector: 'Eurest', coverage: 92, incidents: 1, satisfaction: 4.7, partners: 8 },
{ hub: 'Oakland', enterprise: 'Compass', sector: 'Bon Appétit', coverage: 89, incidents: 2, satisfaction: 4.5, partners: 6 },
{ hub: 'Sacramento', enterprise: 'Compass', sector: 'Chartwells', coverage: 95, incidents: 1, satisfaction: 4.8, partners: 10 },
];
const filteredPartners = partners.filter(p =>
!searchTerm ||
p.partner_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.partner_number?.toLowerCase().includes(searchTerm.toLowerCase())
);
const filteredBusinesses = consolidatedBusinesses.filter(b =>
!searchTerm ||
b.company_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
b.primary_contact?.toLowerCase().includes(searchTerm.toLowerCase())
);
const totalPartnerCount = filteredPartners.length + filteredBusinesses.length;
const totalHubCount = filteredBusinesses.reduce((sum, b) => sum + b.total_hubs, 0) +
filteredPartners.reduce((sum, p) => sum + (p.sites?.length || 0), 0);
const isLoading = partnersLoading || businessesLoading;
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<PageHeader
title="Partners & Operators"
subtitle={`${totalPartnerCount} partners • ${totalHubCount} total hubs • ${sectors.length} sectors • ${enterprises.length} enterprises`}
actions={
<div className="flex gap-2">
<Link to={createPageUrl("AddPartner")}>
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
<Plus className="w-4 h-4 mr-2" />
Add Partner
</Button>
</Link>
<Link to={createPageUrl("Business")}>
<Button variant="outline" className="border-slate-300">
<Building2 className="w-4 h-4 mr-2" />
Business Directory
</Button>
</Link>
</div>
}
/>
{/* Operator Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<Target className="w-8 h-8 text-[#0A39DF]" />
<Badge className="bg-emerald-100 text-emerald-700">+5%</Badge>
</div>
<p className="text-sm text-slate-500">Coverage Rate</p>
<p className="text-3xl font-bold text-[#1C323E]">{operatorMetrics.totalCoverage}%</p>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<AlertTriangle className="w-8 h-8 text-amber-600" />
<Badge className="bg-green-100 text-green-700">Low</Badge>
</div>
<p className="text-sm text-slate-500">Active Incidents</p>
<p className="text-3xl font-bold text-[#1C323E]">{operatorMetrics.activeIncidents}</p>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<CheckCircle2 className="w-8 h-8 text-emerald-600" />
<Badge className="bg-blue-100 text-blue-700">{operatorMetrics.clientSatisfaction}/5.0</Badge>
</div>
<p className="text-sm text-slate-500">Client Satisfaction</p>
<p className="text-3xl font-bold text-[#1C323E]">{operatorMetrics.clientSatisfaction}</p>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<TrendingUp className="w-8 h-8 text-purple-600" />
<Badge className="bg-purple-100 text-purple-700">{operatorMetrics.forecastAccuracy}%</Badge>
</div>
<p className="text-sm text-slate-500">Forecast Accuracy</p>
<p className="text-3xl font-bold text-[#1C323E]">{operatorMetrics.forecastAccuracy}%</p>
</CardContent>
</Card>
</div>
{/* Search */}
<Card className="mb-6 border-slate-200">
<CardContent className="p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
placeholder="Search partners or businesses..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</CardContent>
</Card>
{/* Live Operator Coverage Map */}
<Card className="border-slate-200 shadow-lg mb-8">
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
<CardTitle className="text-base flex items-center gap-2">
<MapPin className="w-5 h-5 text-[#0A39DF]" />
Live Operator Coverage Map
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Real-time coverage across all hubs and sectors</p>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{hubCoverageData.map((hub, index) => (
<div
key={index}
className="p-6 rounded-xl border-2 border-slate-200 hover:border-[#0A39DF] hover:shadow-lg transition-all bg-white"
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
hub.coverage >= 95 ? 'bg-emerald-100' :
hub.coverage >= 90 ? 'bg-blue-100' :
'bg-amber-100'
}`}>
<MapPin className={`w-6 h-6 ${
hub.coverage >= 95 ? 'text-emerald-600' :
hub.coverage >= 90 ? 'text-blue-600' :
'text-amber-600'
}`} />
</div>
<div>
<h4 className="font-bold text-[#1C323E]">{hub.hub}</h4>
<p className="text-xs text-slate-500">{hub.enterprise} {hub.sector}</p>
</div>
</div>
<Badge className={`${
hub.coverage >= 95 ? 'bg-emerald-100 text-emerald-700' :
hub.coverage >= 90 ? 'bg-blue-100 text-blue-700' :
'bg-amber-100 text-amber-700'
} text-lg px-3 py-1 font-bold`}>
{hub.coverage}%
</Badge>
</div>
<div className="grid grid-cols-3 gap-4 text-sm mb-4">
<div className="p-3 bg-slate-50 rounded-lg">
<p className="text-xs text-slate-500 mb-1">Partners</p>
<p className="font-bold text-[#0A39DF]">{hub.partners}</p>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<p className="text-xs text-slate-500 mb-1">Incidents</p>
<p className={`font-bold ${hub.incidents > 0 ? 'text-red-600' : 'text-emerald-600'}`}>
{hub.incidents}
</p>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<p className="text-xs text-slate-500 mb-1">Rating</p>
<p className="font-bold text-amber-600">{hub.satisfaction}/5.0</p>
</div>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${
hub.coverage >= 95 ? 'bg-gradient-to-r from-emerald-500 to-emerald-600' :
hub.coverage >= 90 ? 'bg-gradient-to-r from-blue-500 to-blue-600' :
'bg-gradient-to-r from-amber-500 to-amber-600'
}`}
style={{ width: `${hub.coverage}%` }}
/>
</div>
</div>
))}
</div>
{/* Organizational Hierarchy */}
<div className="mt-8 pt-6 border-t border-slate-200">
<h3 className="text-base font-bold text-[#1C323E] mb-4 flex items-center gap-2">
<Building2 className="w-5 h-5 text-[#0A39DF]" />
Enterprise & Sector Overview
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="p-5 bg-gradient-to-br from-indigo-50 to-white rounded-xl border-2 border-slate-200">
<div className="flex items-center gap-2 mb-3">
<div className="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center">
<Building2 className="w-5 h-5 text-indigo-600" />
</div>
<div>
<p className="text-xs text-slate-500">Enterprises</p>
<p className="text-2xl font-bold text-[#1C323E]">{enterprises.length}</p>
</div>
</div>
<Link to={createPageUrl("EnterpriseManagement")}>
<Button variant="outline" size="sm" className="w-full text-xs border-slate-300">
Manage Enterprises
</Button>
</Link>
</div>
<div className="p-5 bg-gradient-to-br from-purple-50 to-white rounded-xl border-2 border-slate-200">
<div className="flex items-center gap-2 mb-3">
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<MapPin className="w-5 h-5 text-purple-600" />
</div>
<div>
<p className="text-xs text-slate-500">Sectors</p>
<p className="text-2xl font-bold text-[#1C323E]">{sectors.length}</p>
</div>
</div>
<Link to={createPageUrl("SectorManagement")}>
<Button variant="outline" size="sm" className="w-full text-xs border-slate-300">
Manage Sectors
</Button>
</Link>
</div>
<div className="p-5 bg-gradient-to-br from-green-50 to-white rounded-xl border-2 border-slate-200">
<div className="flex items-center gap-2 mb-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<Briefcase className="w-5 h-5 text-green-600" />
</div>
<div>
<p className="text-xs text-slate-500">Partners</p>
<p className="text-2xl font-bold text-[#1C323E]">{totalPartnerCount}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="w-full text-xs border-slate-300"
onClick={() => {
const partnersSection = document.getElementById('partners-section');
partnersSection?.scrollIntoView({ behavior: 'smooth' });
}}
>
View Partners
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Partners Grid/List */}
<div id="partners-section">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-[#1C323E] flex items-center gap-2">
<Briefcase className="w-6 h-6 text-[#0A39DF]" />
Partner Directory
</h2>
<div className="flex items-center gap-2">
<Button
variant={viewMode === "grid" ? "default" : "outline"}
size="icon"
onClick={() => setViewMode("grid")}
className={viewMode === "grid" ? "bg-[#0A39DF] hover:bg-[#0A39DF]/90" : "border-slate-300 text-slate-500 hover:text-[#0A39DF]"}
>
<LayoutGrid className="w-5 h-5" />
</Button>
<Button
variant={viewMode === "list" ? "default" : "outline"}
size="icon"
onClick={() => setViewMode("list")}
className={viewMode === "list" ? "bg-[#0A39DF] hover:bg-[#0A39DF]/90" : "border-slate-300 text-slate-500 hover:text-[#0A39DF]"}
>
<List className="w-5 h-5" />
</Button>
</div>
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-64 bg-slate-100 animate-pulse rounded-xl" />
))}
</div>
) : (filteredPartners.length > 0 || filteredBusinesses.length > 0) ? (
<>
{/* Grid View */}
{viewMode === "grid" && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Display Businesses from Business Directory */}
{filteredBusinesses.map((business, idx) => (
<Card key={`business-${idx}`} className="border-2 border-slate-200 hover:border-[#0A39DF] hover:shadow-xl transition-all">
<CardContent className="p-6">
<div className="flex items-start gap-4 mb-4">
<div className="w-14 h-14 rounded-xl flex items-center justify-center overflow-hidden bg-white border-2 border-slate-200 flex-shrink-0">
{business.company_logo ? (
<img
src={business.company_logo}
alt={business.company_name}
className="w-full h-full object-contain p-2"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-green-500 to-green-700 rounded-lg flex items-center justify-center text-white font-bold text-xl">
{business.company_name?.charAt(0) || 'B'}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-xl text-[#1C323E] mb-1 truncate">
{business.company_name}
</h3>
<p className="text-sm text-slate-500">PN-{String(idx + 1000).padStart(4, '0')}</p>
</div>
<Link to={createPageUrl("Business")}>
<Button variant="ghost" size="icon" className="text-slate-400 hover:text-[#0A39DF] hover:bg-blue-50 flex-shrink-0">
<Edit className="w-4 h-4" />
</Button>
</Link>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between py-1">
<span className="text-sm text-slate-600">Type</span>
<span className="font-semibold text-sm text-[#1C323E]">{business.partner_type}</span>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-sm text-slate-600">Sector</span>
<span className="font-semibold text-sm text-[#1C323E]">{business.sector || '—'}</span>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-sm text-slate-600 flex items-center gap-1">
<MapPin className="w-3 h-3" />
Sites
</span>
<span className="font-semibold text-[#1C323E]">{business.total_hubs}</span>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-sm text-slate-600 flex items-center gap-1">
<DollarSign className="w-3 h-3" />
Terms
</span>
<span className="font-semibold text-[#1C323E]">Net 30</span>
</div>
</div>
<div className="pt-3 mt-3 border-t border-slate-200">
<Badge className="bg-green-100 text-green-700">
Active
</Badge>
</div>
</CardContent>
</Card>
))}
{/* Display Traditional Partners */}
{filteredPartners.map((partner) => (
<Card key={partner.id} className="border-2 border-slate-200 hover:border-[#0A39DF] hover:shadow-xl transition-all">
<CardContent className="p-6">
<div className="flex items-start gap-4 mb-4">
<div className="w-14 h-14 bg-gradient-to-br from-green-500 to-green-700 rounded-xl flex items-center justify-center text-white flex-shrink-0">
<Briefcase className="w-7 h-7" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-xl text-[#1C323E] mb-1 truncate">
{partner.partner_name}
</h3>
<p className="text-sm text-slate-500">{partner.partner_number}</p>
</div>
<Link to={createPageUrl(`EditPartner?id=${partner.id}`)}>
<Button variant="ghost" size="icon" className="text-slate-400 hover:text-[#0A39DF] hover:bg-blue-50 flex-shrink-0">
<Edit className="w-4 h-4" />
</Button>
</Link>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between py-1">
<span className="text-sm text-slate-600">Type</span>
<span className="font-semibold text-sm text-[#1C323E]">{partner.partner_type}</span>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-sm text-slate-600">Sector</span>
<span className="font-semibold text-sm text-[#1C323E]">{partner.sector_name || '—'}</span>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-sm text-slate-600 flex items-center gap-1">
<MapPin className="w-3 h-3" />
Sites
</span>
<span className="font-semibold text-[#1C323E]">{partner.sites?.length || 0}</span>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-sm text-slate-600 flex items-center gap-1">
<DollarSign className="w-3 h-3" />
Terms
</span>
<span className="font-semibold text-[#1C323E]">{partner.payment_terms || 'Net 30'}</span>
</div>
</div>
<div className="pt-3 mt-3 border-t border-slate-200">
<Badge className={partner.is_active ? "bg-green-100 text-green-700" : "bg-slate-100 text-slate-700"}>
{partner.is_active ? "Active" : "Inactive"}
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* List View */}
{viewMode === "list" && (
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50 border-b-2 border-slate-200">
<tr>
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Partner Name</th>
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Type</th>
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Sector</th>
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Hubs/Sites</th>
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Contact</th>
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Status</th>
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Actions</th>
</tr>
</thead>
<tbody>
{/* Display Businesses from Business Directory */}
{filteredBusinesses.map((business, idx) => (
<tr key={`business-${idx}`} className="border-b border-slate-100 hover:bg-slate-50 transition-colors">
<td className="py-4 px-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg flex items-center justify-center overflow-hidden bg-white border-2 border-slate-200 flex-shrink-0">
{business.company_logo ? (
<img
src={business.company_logo}
alt={business.company_name}
className="w-full h-full object-contain p-1"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-blue-500 to-blue-700 rounded-lg flex items-center justify-center text-white font-bold text-sm">
{business.company_name?.charAt(0) || 'B'}
</div>
)}
</div>
<div>
<p className="font-semibold text-[#1C323E]">{business.company_name}</p>
<Badge variant="outline" className="text-xs mt-1">From Business Directory</Badge>
</div>
</div>
</td>
<td className="py-4 px-4 text-sm text-slate-700">{business.partner_type}</td>
<td className="py-4 px-4 text-sm text-slate-700">{business.sector || '—'}</td>
<td className="py-4 px-4 text-center">
<Badge className="bg-blue-100 text-blue-700 font-semibold">
{business.total_hubs}
</Badge>
</td>
<td className="py-4 px-4 text-sm text-slate-700">{business.primary_contact || '—'}</td>
<td className="py-4 px-4 text-center">
<Badge className="bg-green-100 text-green-700">Active</Badge>
</td>
<td className="py-4 px-4 text-center">
<Link to={createPageUrl("Business")}>
<Button variant="outline" size="sm">
<Edit className="w-4 h-4 mr-2" />
Edit
</Button>
</Link>
</td>
</tr>
))}
{/* Display Traditional Partners */}
{filteredPartners.map((partner) => (
<tr key={partner.id} className="border-b border-slate-100 hover:bg-slate-50 transition-colors">
<td className="py-4 px-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-green-500 to-green-700 rounded-lg flex items-center justify-center text-white flex-shrink-0">
<Briefcase className="w-5 h-5" />
</div>
<div>
<p className="font-semibold text-[#1C323E]">{partner.partner_name}</p>
<p className="text-xs text-slate-500">{partner.partner_number}</p>
</div>
</div>
</td>
<td className="py-4 px-4 text-sm text-slate-700">{partner.partner_type}</td>
<td className="py-4 px-4 text-sm text-slate-700">{partner.sector_name || '—'}</td>
<td className="py-4 px-4 text-center">
<Badge className="bg-purple-100 text-purple-700 font-semibold">
{partner.sites?.length || 0}
</Badge>
</td>
<td className="py-4 px-4 text-sm text-slate-700">{partner.primary_contact_name || '—'}</td>
<td className="py-4 px-4 text-center">
<Badge className={partner.is_active ? "bg-green-100 text-green-700" : "bg-slate-100 text-slate-700"}>
{partner.is_active ? "Active" : "Inactive"}
</Badge>
</td>
<td className="py-4 px-4 text-center">
<Link to={createPageUrl(`EditPartner?id=${partner.id}`)}>
<Button variant="outline" size="sm">
<Edit className="w-4 h-4 mr-2" />
Edit
</Button>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</>
) : (
<Card className="border-slate-200">
<CardContent className="p-12 text-center">
<Briefcase className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Partners Found</h3>
<p className="text-slate-500 mb-6">Add your first partner client</p>
<Link to={createPageUrl("AddPartner")}>
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
<Plus className="w-4 h-4 mr-2" />
Add First Partner
</Button>
</Link>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import React from "react";
import { DollarSign } from "lucide-react";
export default function Payroll() {
return (
<div className="p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-3 mb-8">
<DollarSign className="w-8 h-8" />
<h1 className="text-3xl font-bold">Payroll</h1>
</div>
<div className="text-center py-16 bg-white rounded-xl border">
<DollarSign className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<p className="text-slate-600">Payroll management coming soon</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,977 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Shield, Search, Save, Info, ChevronDown, ChevronRight, Users, Calendar,
Package, DollarSign, FileText, Settings as SettingsIcon, BarChart3,
MessageSquare, Briefcase, Building2, Layers, Lock, Unlock, Eye,
CheckCircle2, XCircle, AlertTriangle, Sparkles, UserCog, Plus, Trash2, Copy
} from "lucide-react";
import PageHeader from "../components/common/PageHeader";
import { useToast } from "@/components/ui/use-toast";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// Layer hierarchy configuration
const LAYER_HIERARCHY = [
{
id: "admin",
name: "KROW Admin",
icon: Shield,
color: "from-red-500 to-red-700",
bgColor: "bg-red-50",
borderColor: "border-red-200",
textColor: "text-red-700",
description: "Full platform control and oversight",
level: 1
},
{
id: "procurement",
name: "Procurement",
icon: Briefcase,
color: "from-[#0A39DF] to-[#1C323E]",
bgColor: "bg-blue-50",
borderColor: "border-blue-200",
textColor: "text-blue-700",
description: "Vendor management and rate control",
level: 2
},
{
id: "operator",
name: "Operator",
icon: Building2,
color: "from-emerald-500 to-emerald-700",
bgColor: "bg-emerald-50",
borderColor: "border-emerald-200",
textColor: "text-emerald-700",
description: "Enterprise-wide operations",
level: 3
},
{
id: "sector",
name: "Sector",
icon: Layers,
color: "from-purple-500 to-purple-700",
bgColor: "bg-purple-50",
borderColor: "border-purple-200",
textColor: "text-purple-700",
description: "Location-specific management",
level: 4
},
{
id: "client",
name: "Client",
icon: Users,
color: "from-green-500 to-green-700",
bgColor: "bg-green-50",
borderColor: "border-green-200",
textColor: "text-green-700",
description: "Service ordering and review",
level: 5
},
{
id: "vendor",
name: "Vendor",
icon: Package,
color: "from-amber-500 to-amber-700",
bgColor: "bg-amber-50",
borderColor: "border-amber-200",
textColor: "text-amber-700",
description: "Workforce supply and management",
level: 6
},
{
id: "workforce",
name: "Workforce",
icon: Users,
color: "from-slate-500 to-slate-700",
bgColor: "bg-slate-50",
borderColor: "border-slate-200",
textColor: "text-slate-700",
description: "Shift work and earnings",
level: 7
}
];
// Permission modules for each layer
const PERMISSION_MODULES = {
admin: [
{
id: "platform",
name: "Platform Administration",
icon: Shield,
permissions: [
{ id: "platform.users", name: "Manage All Users", description: "Create, edit, delete users across all layers", critical: true },
{ id: "platform.settings", name: "System Settings", description: "Configure platform-wide settings", critical: true },
{ id: "platform.roles", name: "Role Management", description: "Define and modify role permissions", critical: true },
{ id: "platform.audit", name: "Audit Logs", description: "Access complete system audit trail" },
{ id: "platform.integrations", name: "Integrations", description: "Manage external system connections" },
]
},
{
id: "hierarchy",
name: "Hierarchy Control",
icon: Layers,
permissions: [
{ id: "hierarchy.enterprises", name: "Enterprise Management", description: "Create and manage enterprises" },
{ id: "hierarchy.sectors", name: "Sector Management", description: "Create and manage sectors" },
{ id: "hierarchy.partners", name: "Partner Management", description: "Manage partner relationships" },
{ id: "hierarchy.policies", name: "Global Policies", description: "Set enterprise-wide policies", critical: true },
]
},
{
id: "financial",
name: "Financial Control",
icon: DollarSign,
permissions: [
{ id: "financial.all", name: "All Financials", description: "Access all financial data", critical: true },
{ id: "financial.payments", name: "Process Payments", description: "Approve and process all payments" },
{ id: "financial.payroll", name: "Payroll Management", description: "Process workforce payroll" },
{ id: "financial.reports", name: "Financial Reports", description: "Generate P&L and analytics" },
]
}
],
procurement: [
{
id: "vendors",
name: "Vendor Management",
icon: Package,
permissions: [
{ id: "vendors.view", name: "View Vendors", description: "Access vendor directory" },
{ id: "vendors.onboard", name: "Onboard Vendors", description: "Add new vendors to platform" },
{ id: "vendors.edit", name: "Edit Vendors", description: "Modify vendor information" },
{ id: "vendors.compliance", name: "Compliance Review", description: "Review COI, W9, certifications" },
{ id: "vendors.approve", name: "Approve/Suspend", description: "Change vendor approval status", critical: true },
{ id: "vendors.performance", name: "Performance Data", description: "Access scorecards and KPIs" },
]
},
{
id: "rates",
name: "Rate Management",
icon: DollarSign,
permissions: [
{ id: "rates.view", name: "View Rates", description: "See all vendor pricing" },
{ id: "rates.create", name: "Create Rate Cards", description: "Set up new rate cards" },
{ id: "rates.edit", name: "Edit Rates", description: "Modify existing rates" },
{ id: "rates.approve", name: "Approve Rates", description: "Approve vendor rate submissions", critical: true },
{ id: "rates.markup", name: "Markup Rules", description: "Define markup percentages" },
]
},
{
id: "orders",
name: "Order Oversight",
icon: Calendar,
permissions: [
{ id: "orders.viewAll", name: "View All Orders", description: "Access orders across sectors" },
{ id: "orders.assign", name: "Assign Vendors", description: "Match vendors with orders" },
{ id: "orders.monitor", name: "Monitor Fulfillment", description: "Track order completion" },
]
}
],
operator: [
{
id: "events",
name: "Event Management",
icon: Calendar,
permissions: [
{ id: "events.view", name: "View Events", description: "See events in my enterprise" },
{ id: "events.create", name: "Create Events", description: "Create new event orders" },
{ id: "events.edit", name: "Edit Events", description: "Modify event details" },
{ id: "events.cancel", name: "Cancel Events", description: "Cancel event orders" },
{ id: "events.approve", name: "Approve Events", description: "Approve sector event requests" },
{ id: "events.financials", name: "Event Financials", description: "View event costs and billing" },
]
},
{
id: "sectors",
name: "Sector Oversight",
icon: Building2,
permissions: [
{ id: "sectors.view", name: "View Sectors", description: "See sectors under my enterprise" },
{ id: "sectors.settings", name: "Sector Settings", description: "Configure sector policies" },
{ id: "sectors.vendors", name: "Assign Vendors", description: "Approve vendors for sectors" },
]
},
{
id: "workforce",
name: "Workforce Management",
icon: Users,
permissions: [
{ id: "workforce.view", name: "View Workforce", description: "See staff across enterprise" },
{ id: "workforce.assign", name: "Assign Staff", description: "Schedule staff for events" },
{ id: "workforce.timesheets", name: "Approve Timesheets", description: "Review and approve hours" },
{ id: "workforce.performance", name: "Staff Performance", description: "Access ratings and reviews" },
]
}
],
sector: [
{
id: "events",
name: "Location Events",
icon: Calendar,
permissions: [
{ id: "events.viewMy", name: "View My Events", description: "See events at my location" },
{ id: "events.request", name: "Request Events", description: "Submit new event requests" },
{ id: "events.editMy", name: "Edit My Events", description: "Modify my event details" },
{ id: "events.costs", name: "View Costs", description: "See event billing information" },
]
},
{
id: "staff",
name: "Staff Management",
icon: Users,
permissions: [
{ id: "staff.view", name: "View Staff", description: "See staff at my sector" },
{ id: "staff.schedule", name: "Schedule Staff", description: "Assign staff to shifts" },
{ id: "staff.timesheets", name: "Approve Timesheets", description: "Review hours worked" },
{ id: "staff.rate", name: "Rate Performance", description: "Provide staff feedback" },
]
},
{
id: "vendors",
name: "Vendor Access",
icon: Package,
permissions: [
{ id: "vendors.viewApproved", name: "View Vendors", description: "See approved vendors" },
{ id: "vendors.rates", name: "View Rates", description: "Access rate cards" },
{ id: "vendors.request", name: "Request Services", description: "Submit staffing requests" },
]
}
],
client: [
{
id: "orders",
name: "My Orders",
icon: Calendar,
permissions: [
{ id: "orders.view", name: "View Orders", description: "See my event orders" },
{ id: "orders.create", name: "Create Orders", description: "Request staffing for events" },
{ id: "orders.edit", name: "Edit Orders", description: "Modify orders before confirmation" },
{ id: "orders.cancel", name: "Cancel Orders", description: "Cancel pending orders" },
{ id: "orders.status", name: "Track Status", description: "Monitor order fulfillment" },
]
},
{
id: "vendors",
name: "Vendor Selection",
icon: Package,
permissions: [
{ id: "vendors.browse", name: "Browse Vendors", description: "View available vendors" },
{ id: "vendors.rates", name: "View Rates", description: "See service pricing" },
{ id: "vendors.prefer", name: "Preferred Vendors", description: "Request specific vendors" },
]
},
{
id: "billing",
name: "Billing",
icon: DollarSign,
permissions: [
{ id: "billing.invoices", name: "View Invoices", description: "Access my invoices" },
{ id: "billing.download", name: "Download Invoices", description: "Export invoice PDFs" },
{ id: "billing.analytics", name: "Spend Analytics", description: "View spending trends" },
]
}
],
vendor: [
{
id: "orders",
name: "Order Fulfillment",
icon: Calendar,
permissions: [
{ id: "orders.viewAssigned", name: "View Orders", description: "See assigned orders" },
{ id: "orders.respond", name: "Accept/Decline", description: "Respond to order requests" },
{ id: "orders.update", name: "Update Status", description: "Mark order progress" },
{ id: "orders.details", name: "Order Details", description: "Access requirements" },
]
},
{
id: "workforce",
name: "My Workforce",
icon: Users,
permissions: [
{ id: "workforce.view", name: "View Staff", description: "See my workforce" },
{ id: "workforce.add", name: "Add Staff", description: "Onboard new workers" },
{ id: "workforce.edit", name: "Edit Staff", description: "Update staff info" },
{ id: "workforce.assign", name: "Assign Staff", description: "Schedule for orders" },
{ id: "workforce.compliance", name: "Manage Compliance", description: "Track certifications" },
]
},
{
id: "rates",
name: "My Rates",
icon: DollarSign,
permissions: [
{ id: "rates.viewMy", name: "View Rates", description: "See my approved rates" },
{ id: "rates.propose", name: "Propose Rates", description: "Submit rate proposals" },
{ id: "rates.history", name: "Rate History", description: "Track rate changes" },
]
},
{
id: "invoices",
name: "Invoicing",
icon: FileText,
permissions: [
{ id: "invoices.view", name: "View Invoices", description: "Access my invoices" },
{ id: "invoices.create", name: "Create Invoices", description: "Generate invoices" },
{ id: "invoices.track", name: "Track Payments", description: "Monitor payment status" },
]
}
],
workforce: [
{
id: "shifts",
name: "My Shifts",
icon: Calendar,
permissions: [
{ id: "shifts.view", name: "View Schedule", description: "See upcoming shifts" },
{ id: "shifts.clock", name: "Clock In/Out", description: "Record shift times" },
{ id: "shifts.timeoff", name: "Request Time Off", description: "Submit time off requests" },
{ id: "shifts.history", name: "Shift History", description: "See past shifts" },
]
},
{
id: "profile",
name: "My Profile",
icon: Users,
permissions: [
{ id: "profile.view", name: "View Profile", description: "See my worker profile" },
{ id: "profile.edit", name: "Edit Contact", description: "Update phone/email" },
{ id: "profile.availability", name: "Update Availability", description: "Set available times" },
{ id: "profile.certs", name: "Upload Certs", description: "Add certifications" },
]
},
{
id: "earnings",
name: "Earnings",
icon: DollarSign,
permissions: [
{ id: "earnings.view", name: "View Earnings", description: "See pay and hours" },
{ id: "earnings.timesheets", name: "View Timesheets", description: "Access timesheet records" },
{ id: "earnings.history", name: "Payment History", description: "See past payments" },
{ id: "earnings.download", name: "Download Stubs", description: "Export pay stubs" },
]
}
]
};
// Default position templates
const DEFAULT_POSITION_TEMPLATES = [
{ id: "manager", position: "Manager", layer: "operator", description: "Full operational access", permissions: ["events.view", "events.create", "events.edit", "events.approve", "sectors.view", "workforce.view", "workforce.assign"] },
{ id: "supervisor", position: "Supervisor", layer: "sector", description: "Location-level management", permissions: ["events.viewMy", "events.request", "staff.view", "staff.schedule", "staff.timesheets"] },
{ id: "coordinator", position: "Coordinator", layer: "client", description: "Order coordination", permissions: ["orders.view", "orders.create", "orders.status", "vendors.browse"] },
{ id: "team_lead", position: "Team Lead", layer: "vendor", description: "Workforce team management", permissions: ["orders.viewAssigned", "workforce.view", "workforce.assign", "rates.viewMy"] },
{ id: "staff", position: "Staff Member", layer: "workforce", description: "Basic shift access", permissions: ["shifts.view", "shifts.clock", "profile.view", "earnings.view"] },
];
export default function Permissions() {
const [activeTab, setActiveTab] = useState("layers");
const [selectedLayer, setSelectedLayer] = useState("operator");
const [searchTerm, setSearchTerm] = useState("");
const [expandedModules, setExpandedModules] = useState({});
const [permissions, setPermissions] = useState({});
const [positionTemplates, setPositionTemplates] = useState(DEFAULT_POSITION_TEMPLATES);
const [showAddPosition, setShowAddPosition] = useState(false);
const [newPosition, setNewPosition] = useState({ position: "", description: "" });
const [editingPosition, setEditingPosition] = useState(null);
const { toast } = useToast();
const { data: user } = useQuery({
queryKey: ['current-user-permissions'],
queryFn: () => base44.auth.me(),
});
const userRole = user?.user_role || user?.role || "admin";
// Non-admin users can only see their own layer
const effectiveLayer = userRole === "admin" ? selectedLayer : userRole;
const selectedLayerConfig = LAYER_HIERARCHY.find(l => l.id === effectiveLayer);
const modules = PERMISSION_MODULES[effectiveLayer] || [];
// Filter layer hierarchy for non-admin users
const visibleLayers = userRole === "admin" ? LAYER_HIERARCHY : LAYER_HIERARCHY.filter(l => l.id === userRole);
// Initialize permissions with all enabled for demo
React.useEffect(() => {
const newPermissions = {};
modules.forEach(module => {
module.permissions.forEach(perm => {
newPermissions[perm.id] = true;
});
});
setPermissions(newPermissions);
// Expand all modules by default
const expanded = {};
modules.forEach(m => expanded[m.id] = true);
setExpandedModules(expanded);
}, [selectedLayer]);
const toggleModule = (moduleId) => {
setExpandedModules(prev => ({
...prev,
[moduleId]: !prev[moduleId]
}));
};
const togglePermission = (permId) => {
setPermissions(prev => ({
...prev,
[permId]: !prev[permId]
}));
};
const toggleAllInModule = (module, value) => {
const newPerms = { ...permissions };
module.permissions.forEach(p => {
newPerms[p.id] = value;
});
setPermissions(newPerms);
};
const handleSave = () => {
toast({
title: "Permissions Saved",
description: `${selectedLayerConfig.name} layer permissions updated successfully.`,
});
};
const handleAddPosition = () => {
if (!newPosition.position) return;
const id = newPosition.position.toLowerCase().replace(/\s+/g, '_');
setPositionTemplates([...positionTemplates, { ...newPosition, id, layer: effectiveLayer, permissions: [] }]);
setNewPosition({ position: "", description: "" });
setShowAddPosition(false);
toast({ title: "Position Added", description: `${newPosition.position} template created.` });
};
const handleDeletePosition = (id) => {
setPositionTemplates(positionTemplates.filter(p => p.id !== id));
toast({ title: "Position Deleted", description: "Position template removed." });
};
const handleEditPositionPermissions = (position) => {
setEditingPosition(position);
setSelectedLayer(position.layer);
// Load position's permissions
const newPerms = {};
position.permissions.forEach(p => newPerms[p] = true);
setPermissions(newPerms);
};
const handleSavePositionPermissions = () => {
if (!editingPosition) return;
const enabledPerms = Object.entries(permissions).filter(([_, v]) => v).map(([k]) => k);
setPositionTemplates(positionTemplates.map(p =>
p.id === editingPosition.id ? { ...p, permissions: enabledPerms } : p
));
setEditingPosition(null);
toast({ title: "Position Updated", description: `${editingPosition.position} permissions saved.` });
};
const filteredModules = modules.map(module => ({
...module,
permissions: module.permissions.filter(perm =>
perm.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
perm.description.toLowerCase().includes(searchTerm.toLowerCase())
)
})).filter(module => module.permissions.length > 0);
const enabledCount = Object.values(permissions).filter(Boolean).length;
const totalCount = modules.reduce((sum, m) => sum + m.permissions.length, 0);
return (
<TooltipProvider>
<div className="p-4 md:p-8 bg-slate-50 min-h-screen overflow-hidden">
<div className="max-w-7xl mx-auto">
<PageHeader
title="Permissions Management"
subtitle="Configure access control for each layer in the KROW ecosystem"
/>
{/* Tab Navigation */}
<div className="bg-white rounded-xl border border-slate-200 p-1.5 inline-flex gap-1 mb-6 shadow-sm">
<button
onClick={() => { setActiveTab("layers"); setEditingPosition(null); }}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
activeTab === "layers"
? "bg-gradient-to-r from-[#0A39DF] to-[#1C323E] text-white shadow-md"
: "text-slate-600 hover:bg-slate-100"
}`}
>
<Layers className="w-4 h-4" />
Layer Permissions
</button>
<button
onClick={() => setActiveTab("positions")}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
activeTab === "positions"
? "bg-gradient-to-r from-[#0A39DF] to-[#1C323E] text-white shadow-md"
: "text-slate-600 hover:bg-slate-100"
}`}
>
<UserCog className="w-4 h-4" />
Position Templates
</button>
</div>
{activeTab === "positions" && !editingPosition && (
<>
{/* Position Templates */}
<Card className="mb-4 border-slate-200 shadow-lg">
<CardHeader className="bg-white border-b border-slate-200 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-[#0A39DF] flex items-center justify-center">
<UserCog className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-slate-900 text-lg">Position Templates</CardTitle>
<p className="text-sm text-slate-500">Define default permissions for job positions</p>
</div>
</div>
<Button size="sm" onClick={() => setShowAddPosition(true)} className="bg-[#0A39DF] hover:bg-[#0831b8]">
<Plus className="w-4 h-4 mr-1" /> Add Position
</Button>
</div>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{positionTemplates.map((template) => {
const layerConfig = LAYER_HIERARCHY.find(l => l.id === template.layer);
const layerModules = PERMISSION_MODULES[template.layer] || [];
// Get permission names for display
const permissionNames = template.permissions.map(permId => {
for (const module of layerModules) {
const found = module.permissions.find(p => p.id === permId);
if (found) return found.name;
}
return permId;
});
return (
<div key={template.id} className={`p-4 rounded-xl border-2 ${layerConfig?.borderColor || 'border-slate-200'} bg-white`}>
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-bold text-slate-900">{template.position}</h4>
<Badge className={`text-[10px] mt-1 ${layerConfig?.textColor || ''} bg-white border`}>
{layerConfig?.name || template.layer}
</Badge>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEditPositionPermissions(template)}>
<Shield className="w-3.5 h-3.5 text-blue-600" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleDeletePosition(template.id)}>
<Trash2 className="w-3.5 h-3.5 text-red-500" />
</Button>
</div>
</div>
<p className="text-xs text-slate-600 mb-2">{template.description}</p>
<div className="mt-3 space-y-3">
{layerModules.map((module) => {
const ModuleIcon = module.icon;
const moduleEnabledCount = module.permissions.filter(p => template.permissions.includes(p.id)).length;
const allModuleEnabled = moduleEnabledCount === module.permissions.length;
return (
<div key={module.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
{/* Module Header */}
<div className="flex items-center justify-between px-4 py-3 bg-slate-50 border-b border-slate-100">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-emerald-100 flex items-center justify-center">
<ModuleIcon className="w-4 h-4 text-emerald-600" />
</div>
<div>
<p className="text-sm font-semibold text-slate-900">{module.name}</p>
<p className="text-[10px] text-slate-500">{moduleEnabledCount} of {module.permissions.length} enabled</p>
</div>
</div>
<button
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-slate-700"
onClick={() => {
const modulePermIds = module.permissions.map(p => p.id);
let newPerms;
if (allModuleEnabled) {
newPerms = template.permissions.filter(p => !modulePermIds.includes(p));
} else {
newPerms = [...new Set([...template.permissions, ...modulePermIds])];
}
setPositionTemplates(positionTemplates.map(p =>
p.id === template.id ? { ...p, permissions: newPerms } : p
));
toast({ title: allModuleEnabled ? "Module Disabled" : "Module Enabled", description: `${module.name} permissions updated` });
}}
>
<XCircle className="w-3 h-3" />
{allModuleEnabled ? 'Disable All' : 'Enable All'}
</button>
</div>
{/* Permissions List */}
<div className="divide-y divide-slate-100">
{module.permissions.map((perm) => {
const isEnabled = template.permissions.includes(perm.id);
return (
<div
key={perm.id}
className="flex items-center justify-between px-4 py-2.5 hover:bg-slate-50 cursor-pointer transition-colors"
onClick={() => {
const newPerms = isEnabled
? template.permissions.filter(p => p !== perm.id)
: [...template.permissions, perm.id];
setPositionTemplates(positionTemplates.map(p =>
p.id === template.id ? { ...p, permissions: newPerms } : p
));
toast({ title: isEnabled ? "Permission Disabled" : "Permission Enabled", description: `${perm.name} ${isEnabled ? 'removed from' : 'added to'} ${template.position}` });
}}
>
<div className="flex items-center gap-3">
<div className="w-6 h-6 rounded-md bg-slate-100 flex items-center justify-center">
<Unlock className="w-3 h-3 text-slate-400" />
</div>
<div>
<p className="text-xs font-medium text-slate-900">{perm.name}</p>
<p className="text-[10px] text-slate-500">{perm.description}</p>
</div>
</div>
<div className={`w-10 h-5 rounded-full p-0.5 transition-colors ${isEnabled ? 'bg-emerald-500' : 'bg-slate-300'}`}>
<div className={`w-4 h-4 rounded-full bg-white shadow transition-transform ${isEnabled ? 'translate-x-5' : 'translate-x-0'}`} />
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Add Position Dialog */}
<Dialog open={showAddPosition} onOpenChange={setShowAddPosition}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Position Template</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="text-sm font-medium">Position Title</label>
<Input
value={newPosition.position}
onChange={(e) => setNewPosition({ ...newPosition, position: e.target.value })}
placeholder="e.g., Regional Manager"
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">Description</label>
<Input
value={newPosition.description}
onChange={(e) => setNewPosition({ ...newPosition, description: e.target.value })}
placeholder="Brief description of access level"
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddPosition(false)}>Cancel</Button>
<Button onClick={handleAddPosition} className="bg-[#0A39DF]">Create Position</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
{editingPosition && (
<div className="mb-4 p-3 bg-purple-50 border border-purple-200 rounded-xl flex items-center justify-between">
<div className="flex items-center gap-3">
<UserCog className="w-5 h-5 text-purple-600" />
<div>
<p className="font-bold text-purple-900">Editing: {editingPosition.position}</p>
<p className="text-xs text-purple-700">Configure permissions for this position template</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setEditingPosition(null)}>Cancel</Button>
<Button size="sm" onClick={handleSavePositionPermissions} className="bg-purple-600 hover:bg-purple-700">
<Save className="w-4 h-4 mr-1" /> Save Position
</Button>
</div>
</div>
)}
{(activeTab === "layers" || editingPosition) && (
<>
{/* Layer Hierarchy Visual */}
<Card className="mb-4 border-slate-200 shadow-lg overflow-hidden">
<CardHeader className="bg-slate-800 text-white">
<div className="flex items-center gap-3">
<Layers className="w-6 h-6" />
<div>
<CardTitle className="text-white">KROW Ecosystem Layers</CardTitle>
<p className="text-slate-300 text-sm mt-1">Select a layer to configure its permissions</p>
</div>
</div>
</CardHeader>
<CardContent className="p-6 bg-gradient-to-b from-slate-100 to-white">
{/* Horizontal Layer Flow */}
<div className="flex items-center justify-center gap-2 pb-2 flex-wrap">
{visibleLayers.map((layer, index) => {
const Icon = layer.icon;
const isSelected = effectiveLayer === layer.id;
return (
<React.Fragment key={layer.id}>
<button
onClick={() => setSelectedLayer(layer.id)}
className={`flex-shrink-0 relative group transition-all duration-200 ${
isSelected ? 'scale-105 z-10' : 'hover:scale-102'
} ${userRole !== "admin" ? 'cursor-default' : ''}`}
disabled={userRole !== "admin"}
>
<div className={`
w-16 h-16 md:w-20 md:h-20 rounded-xl flex flex-col items-center justify-center gap-1
transition-all duration-200 border-2
${isSelected
? `bg-[#0A39DF] text-white shadow-lg border-transparent`
: `${layer.bgColor} ${layer.borderColor} ${layer.textColor} hover:shadow-sm`
}
`}>
<Icon className={`w-5 h-5 md:w-6 md:h-6 ${isSelected ? 'text-white' : ''}`} />
<span className="text-[10px] md:text-xs font-bold text-center px-1 leading-tight">{layer.name}</span>
</div>
</button>
{index < visibleLayers.length - 1 && (
<div className="flex-shrink-0 w-4 md:w-6 h-0.5 bg-slate-300"></div>
)}
</React.Fragment>
);
})}
</div>
{/* Selected Layer Info */}
{selectedLayerConfig && (
<div className={`mt-4 p-3 rounded-lg ${selectedLayerConfig.bgColor} ${selectedLayerConfig.borderColor} border`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-lg bg-[#0A39DF] flex items-center justify-center`}>
<selectedLayerConfig.icon className="w-4 h-4 text-white" />
</div>
<div>
<h3 className={`font-bold text-sm ${selectedLayerConfig.textColor}`}>
{selectedLayerConfig.name} Layer
</h3>
<p className="text-xs text-slate-600">{selectedLayerConfig.description}</p>
</div>
</div>
<div className="text-right">
<p className="text-lg font-bold text-slate-900">{enabledCount}/{totalCount}</p>
<p className="text-[10px] text-slate-500">enabled</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Search */}
<div className="mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search permissions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-10 bg-white border-slate-300 shadow-sm"
/>
</div>
</div>
{/* Permission Modules */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 max-h-[calc(100vh-480px)] overflow-y-auto pb-4">
{filteredModules.map((module) => {
const Icon = module.icon;
const isExpanded = expandedModules[module.id] !== false;
const moduleEnabled = module.permissions.filter(p => permissions[p.id]).length;
const moduleTotal = module.permissions.length;
const allEnabled = moduleEnabled === moduleTotal;
return (
<Card key={module.id} className="border-slate-200 shadow-md overflow-hidden">
<CardHeader
className={`cursor-pointer transition-colors ${
isExpanded ? 'bg-slate-100' : 'bg-white hover:bg-slate-50'
}`}
onClick={() => toggleModule(module.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-slate-400" />
) : (
<ChevronRight className="w-5 h-5 text-slate-400" />
)}
<div className={`w-10 h-10 rounded-xl bg-[#0A39DF] flex items-center justify-center`}>
<Icon className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-base">{module.name}</CardTitle>
<p className="text-xs text-slate-500 mt-0.5">
{moduleEnabled} of {moduleTotal} enabled
</p>
</div>
</div>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => toggleAllInModule(module, !allEnabled)}
className="text-xs"
>
{allEnabled ? (
<><XCircle className="w-4 h-4 mr-1" /> Disable All</>
) : (
<><CheckCircle2 className="w-4 h-4 mr-1" /> Enable All</>
)}
</Button>
</TooltipTrigger>
<TooltipContent>Toggle all permissions in this module</TooltipContent>
</Tooltip>
</div>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="p-0 divide-y divide-slate-100">
{module.permissions.map((perm) => {
const isEnabled = permissions[perm.id];
return (
<div
key={perm.id}
className={`flex items-center justify-between p-4 transition-colors ${
isEnabled ? 'bg-white' : 'bg-slate-50'
}`}
>
<div className="flex items-center gap-3 flex-1">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
isEnabled ? 'bg-green-100' : 'bg-slate-200'
}`}>
{isEnabled ? (
<Unlock className="w-4 h-4 text-green-600" />
) : (
<Lock className="w-4 h-4 text-slate-400" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className={`text-sm font-medium ${isEnabled ? 'text-slate-900' : 'text-slate-500'}`}>
{perm.name}
</span>
{perm.critical && (
<Tooltip>
<TooltipTrigger>
<AlertTriangle className="w-4 h-4 text-amber-500" />
</TooltipTrigger>
<TooltipContent>Critical permission - use with caution</TooltipContent>
</Tooltip>
)}
</div>
<p className="text-xs text-slate-500">{perm.description}</p>
</div>
</div>
<Switch
checked={isEnabled}
onCheckedChange={() => togglePermission(perm.id)}
className="data-[state=checked]:bg-green-600"
/>
</div>
);
})}
</CardContent>
)}
</Card>
);
})}
</div>
{/* Save Footer */}
{!editingPosition && (
<div className="mt-4 p-4 bg-white rounded-xl border border-slate-200 shadow-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-xl bg-[#0A39DF] flex items-center justify-center`}>
<selectedLayerConfig.icon className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-bold text-slate-900 text-sm">{selectedLayerConfig.name} Layer</p>
<p className="text-xs text-slate-500">
{enabledCount} enabled {totalCount - enabledCount} disabled
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="border-slate-300">
Reset
</Button>
<Button size="sm" onClick={handleSave} className="bg-[#0A39DF] hover:bg-[#0831b8]">
<Save className="w-4 h-4 mr-1" />
Save
</Button>
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
</TooltipProvider>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,508 @@
import React, { useState, useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Zap, Send, Check, Edit3, MapPin, Clock, Users, AlertCircle, Sparkles, Mic, X, Calendar as CalendarIcon, ArrowLeft } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { motion, AnimatePresence } from "framer-motion";
import { format } from "date-fns";
// Helper function to convert 24-hour time to 12-hour format
const convertTo12Hour = (time24) => {
if (!time24 || time24 === "—") return time24;
try {
const parts = time24.split(':');
if (!parts || parts.length < 2) return time24;
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
if (isNaN(hours) || isNaN(minutes)) return time24;
const period = hours >= 12 ? 'PM' : 'AM';
const hours12 = hours % 12 || 12;
const minutesStr = minutes.toString().padStart(2, '0');
return `${hours12}:${minutesStr} ${period}`;
} catch (error) {
console.error('Error converting time:', error);
return time24;
}
};
export default function RapidOrder() {
const navigate = useNavigate();
const { toast } = useToast();
const queryClient = useQueryClient();
const [message, setMessage] = useState("");
const [conversation, setConversation] = useState([]);
const [detectedOrder, setDetectedOrder] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const [isListening, setIsListening] = useState(false);
const [submissionTime, setSubmissionTime] = useState(null);
const { data: user } = useQuery({
queryKey: ['current-user-rapid'],
queryFn: () => base44.auth.me(),
});
const { data: businesses } = useQuery({
queryKey: ['user-businesses'],
queryFn: () => base44.entities.Business.filter({ contact_name: user?.full_name }),
enabled: !!user,
initialData: [],
});
const createRapidOrderMutation = useMutation({
mutationFn: (orderData) => base44.entities.Event.create(orderData),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: ['client-events'] });
const now = new Date();
setSubmissionTime(now);
toast({
title: "✅ RAPID Order Created",
description: "Order sent to preferred vendor with priority notification",
});
// Show success message in chat
setConversation(prev => [...prev, {
role: 'assistant',
content: `🚀 **Order Submitted Successfully!**\n\nOrder Number: **${data.id?.slice(-8) || 'RAPID-001'}**\nSubmitted: **${format(now, 'h:mm:ss a')}**\n\nYour preferred vendor has been notified and will assign staff shortly.`,
isSuccess: true
}]);
// Reset after delay
setTimeout(() => {
navigate(createPageUrl("ClientDashboard"));
}, 3000);
},
});
const analyzeMessage = async (msg) => {
setIsProcessing(true);
setConversation(prev => [...prev, { role: 'user', content: msg }]);
try {
const response = await base44.integrations.Core.InvokeLLM({
prompt: `You are an order assistant. Analyze this message and extract order details:
Message: "${msg}"
Current user: ${user?.full_name}
User's locations: ${businesses.map(b => b.business_name).join(', ')}
Extract:
1. Urgency keywords (ASAP, today, emergency, call out, urgent, rapid, now)
2. Role/position needed (cook, bartender, server, dishwasher, etc.)
3. Number of staff (if mentioned, parse the number correctly - e.g., "5 cooks" = 5, "need 3 servers" = 3)
4. End time (if mentioned, extract the time - e.g., "until 5am" = "05:00", "until 11pm" = "23:00", "until midnight" = "00:00")
5. Location (if mentioned, otherwise use first available location)
IMPORTANT:
- Make sure to correctly extract the number of staff from phrases like "need 5 cooks" or "I need 3 bartenders"
- If end time is mentioned (e.g., "until 5am", "till 11pm"), extract it in 24-hour format (e.g., "05:00", "23:00")
- If no end time is mentioned, leave it as null
Return a concise summary.`,
response_json_schema: {
type: "object",
properties: {
is_urgent: { type: "boolean" },
role: { type: "string" },
count: { type: "number" },
location: { type: "string" },
end_time: { type: "string" }
}
}
});
const parsed = response;
const primaryLocation = businesses[0]?.business_name || "Primary Location";
// Ensure count is properly set - default to 1 if not detected
// CRITICAL: For RAPID orders, use the EXACT count parsed, no modifications
const staffCount = parsed.count && parsed.count > 0 ? Math.floor(parsed.count) : 1;
// Get current time for start_time (when ASAP)
const now = new Date();
const currentTime = format(now, 'HH:mm');
// Handle end_time - use parsed end time or current time as confirmation time
const endTime = parsed.end_time || currentTime;
const order = {
is_rapid: parsed.is_urgent || true,
role: parsed.role || "Staff Member",
count: staffCount,
location: parsed.location || primaryLocation,
start_time: currentTime, // Always use current time for ASAP orders (24-hour format for storage)
end_time: endTime, // Use parsed end time or current time (24-hour format for storage)
start_time_display: convertTo12Hour(currentTime), // For display
end_time_display: convertTo12Hour(endTime), // For display
business_name: primaryLocation,
hub: businesses[0]?.hub_building || "Main Hub",
submission_time: now // Store the actual submission time
};
setDetectedOrder(order);
const aiMessage = `Is this a RAPID ORDER for **${order.count} ${order.role}${order.count > 1 ? 's' : ''}** at **${order.location}**?\n\nStart Time: ${order.start_time_display}\nEnd Time: ${order.end_time_display}`;
setConversation(prev => [...prev, {
role: 'assistant',
content: aiMessage,
showConfirm: true
}]);
} catch (error) {
setConversation(prev => [...prev, {
role: 'assistant',
content: "I couldn't process that. Please provide more details like: role needed, how many, and when."
}]);
} finally {
setIsProcessing(false);
}
};
const handleSendMessage = () => {
if (!message.trim()) return;
analyzeMessage(message);
setMessage("");
};
const handleVoiceInput = () => {
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
toast({
title: "Voice not supported",
description: "Your browser doesn't support voice input",
variant: "destructive",
});
return;
}
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.onstart = () => setIsListening(true);
recognition.onend = () => setIsListening(false);
recognition.onresult = (event) => {
const transcript = event.results[0][0].transcript;
setMessage(transcript);
analyzeMessage(transcript);
};
recognition.onerror = () => {
setIsListening(false);
toast({
title: "Voice input failed",
description: "Please try typing instead",
variant: "destructive",
});
};
recognition.start();
};
const handleConfirmOrder = () => {
if (!detectedOrder) return;
const now = new Date();
const confirmTime = format(now, 'HH:mm');
const confirmTime12Hour = convertTo12Hour(confirmTime);
// Create comprehensive order data with proper requested field and actual times
// CRITICAL: For RAPID orders, requested must exactly match the count - no additions
const exactCount = Math.floor(Number(detectedOrder.count));
const orderData = {
event_name: `RAPID: ${exactCount} ${detectedOrder.role}${exactCount > 1 ? 's' : ''}`,
is_rapid: true,
status: "Pending",
business_name: detectedOrder.business_name,
hub: detectedOrder.hub,
event_location: detectedOrder.location,
date: now.toISOString().split('T')[0],
requested: exactCount, // EXACT count requested, no modifications
client_name: user?.full_name,
client_email: user?.email,
notes: `RAPID ORDER - Submitted at ${detectedOrder.start_time_display} - Confirmed at ${confirmTime12Hour}\nStart: ${detectedOrder.start_time_display} | End: ${detectedOrder.end_time_display}`,
shifts: [{
shift_name: "Emergency Shift",
location: detectedOrder.location,
roles: [{
role: detectedOrder.role,
count: exactCount, // Use exact count, no modifications
start_time: detectedOrder.start_time, // Store in 24-hour format
end_time: detectedOrder.end_time // Store in 24-hour format
}]
}]
};
console.log('Creating RAPID order with data:', orderData); // Debug log
createRapidOrderMutation.mutate(orderData);
};
const handleEditOrder = () => {
setConversation(prev => [...prev, {
role: 'assistant',
content: "Please describe what you'd like to change."
}]);
setDetectedOrder(null);
};
return (
<div className="min-h-screen bg-gradient-to-br from-red-50 via-orange-50 to-yellow-50 p-6">
<div className="max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl("ClientDashboard"))}
className="hover:bg-white/50"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div>
<div className="flex items-center gap-3 mb-2">
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-orange-500 rounded-xl flex items-center justify-center shadow-lg">
<Zap className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-red-700 flex items-center gap-2">
<Sparkles className="w-6 h-6" />
RAPID Order
</h1>
<p className="text-sm text-red-600 mt-1">Emergency staffing in minutes</p>
</div>
</div>
</div>
</div>
<div className="text-right">
<div className="flex items-center gap-2 text-sm text-slate-600 mb-1">
<CalendarIcon className="w-4 h-4" />
<span>{format(new Date(), 'EEEE, MMMM d, yyyy')}</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-600">
<Clock className="w-4 h-4" />
<span>{format(new Date(), 'h:mm a')}</span>
</div>
</div>
</div>
<Card className="bg-white border-2 border-red-300 shadow-2xl">
<CardHeader className="border-b border-red-200 bg-gradient-to-r from-red-50 to-orange-50">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-bold text-red-700">
Tell us what you need
</CardTitle>
<Badge className="bg-red-600 text-white font-bold text-sm px-4 py-2 shadow-md animate-pulse">
URGENT
</Badge>
</div>
</CardHeader>
<CardContent className="p-6">
{/* Chat Messages */}
<div className="space-y-4 mb-6 max-h-[500px] overflow-y-auto">
{conversation.length === 0 && (
<div className="text-center py-12">
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-red-500 to-orange-500 rounded-2xl flex items-center justify-center shadow-2xl">
<Zap className="w-10 h-10 text-white" />
</div>
<h3 className="font-bold text-2xl text-slate-900 mb-3">Need staff urgently?</h3>
<p className="text-base text-slate-600 mb-6">Type or speak what you need, I'll handle the rest</p>
<div className="text-left max-w-lg mx-auto space-y-3">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 p-4 rounded-xl border-2 border-blue-200 text-sm">
<strong className="text-blue-900">Example:</strong> <span className="text-slate-700">"We had a call out. Need 2 cooks ASAP"</span>
</div>
<div className="bg-gradient-to-r from-purple-50 to-pink-50 p-4 rounded-xl border-2 border-purple-200 text-sm">
<strong className="text-purple-900">Example:</strong> <span className="text-slate-700">"Need 5 bartenders ASAP until 5am"</span>
</div>
<div className="bg-gradient-to-r from-green-50 to-emerald-50 p-4 rounded-xl border-2 border-green-200 text-sm">
<strong className="text-green-900">Example:</strong> <span className="text-slate-700">"Emergency! Need 3 servers right now till midnight"</span>
</div>
</div>
</div>
)}
<AnimatePresence>
{conversation.map((msg, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-[85%] ${
msg.role === 'user'
? 'bg-gradient-to-br from-blue-600 to-blue-700 text-white'
: msg.isSuccess
? 'bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300'
: 'bg-white border-2 border-red-200'
} rounded-2xl p-5 shadow-lg`}>
{msg.role === 'assistant' && !msg.isSuccess && (
<div className="flex items-center gap-2 mb-3">
<div className="w-7 h-7 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center">
<Sparkles className="w-4 h-4 text-white" />
</div>
<span className="text-xs font-bold text-red-600">AI Assistant</span>
</div>
)}
<p className={`text-base whitespace-pre-line ${
msg.role === 'user' ? 'text-white' :
msg.isSuccess ? 'text-green-900' :
'text-slate-900'
}`}>
{msg.content}
</p>
{msg.showConfirm && detectedOrder && (
<div className="mt-5 space-y-4">
<div className="grid grid-cols-2 gap-4 p-4 bg-gradient-to-br from-slate-50 to-blue-50 rounded-xl border-2 border-blue-300">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-slate-500 font-semibold">Staff Needed</p>
<p className="font-bold text-base text-slate-900">{detectedOrder.count} {detectedOrder.role}{detectedOrder.count > 1 ? 's' : ''}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
<MapPin className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-slate-500 font-semibold">Location</p>
<p className="font-bold text-base text-slate-900">{detectedOrder.location}</p>
</div>
</div>
<div className="flex items-center gap-3 col-span-2">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
<Clock className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-slate-500 font-semibold">Time</p>
<p className="font-bold text-base text-slate-900">
Start: {detectedOrder.start_time_display} | End: {detectedOrder.end_time_display}
</p>
</div>
</div>
</div>
<div className="flex gap-3">
<Button
onClick={handleConfirmOrder}
disabled={createRapidOrderMutation.isPending}
className="flex-1 bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-xl text-base py-6"
>
<Check className="w-5 h-5 mr-2" />
{createRapidOrderMutation.isPending ? "Creating..." : "CONFIRM & SEND"}
</Button>
<Button
onClick={handleEditOrder}
variant="outline"
className="border-2 border-red-300 hover:bg-red-50 text-base py-6"
>
<Edit3 className="w-5 h-5 mr-2" />
EDIT
</Button>
</div>
</div>
)}
</div>
</motion.div>
))}
</AnimatePresence>
{isProcessing && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex justify-start"
>
<div className="bg-white border-2 border-red-200 rounded-2xl p-5 shadow-lg">
<div className="flex items-center gap-3">
<div className="w-7 h-7 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center animate-pulse">
<Sparkles className="w-4 h-4 text-white" />
</div>
<span className="text-base text-slate-600">Processing your request...</span>
</div>
</div>
</motion.div>
)}
</div>
{/* Input */}
<div className="space-y-4">
<div className="flex gap-2">
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
placeholder="Type or speak... (e.g., 'Need 5 cooks ASAP until 5am')"
className="flex-1 border-2 border-red-300 focus:border-red-500 text-base resize-none"
rows={3}
disabled={isProcessing}
/>
</div>
<div className="flex gap-2">
<Button
onClick={handleVoiceInput}
disabled={isProcessing || isListening}
variant="outline"
className={`border-2 ${isListening ? 'border-red-500 bg-red-50' : 'border-red-300'} hover:bg-red-50 text-base py-6 px-6`}
>
<Mic className={`w-5 h-5 mr-2 ${isListening ? 'animate-pulse text-red-600' : ''}`} />
{isListening ? 'Listening...' : 'Speak'}
</Button>
<Button
onClick={handleSendMessage}
disabled={!message.trim() || isProcessing}
className="flex-1 bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-xl text-base py-6"
>
<Send className="w-5 h-5 mr-2" />
Send Message
</Button>
</div>
</div>
{/* Helper Text */}
<div className="mt-4 p-4 bg-blue-50 border-2 border-blue-200 rounded-xl">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800">
<strong>Tip:</strong> Include role, quantity, and urgency for fastest processing.
Optionally add end time like "until 5am" or "till midnight".
AI will auto-detect your location and send to your preferred vendor with priority notification.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import React, { useState } from "react";
import { useNavigate, Link } from "react-router-dom";
import { createUserWithEmailAndPassword } from "firebase/auth";
import { auth } from "@/firebase";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2 } from "lucide-react";
export default function Register() {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const validatePassword = (password) => {
if (password.length < 6) {
return "Password must be at least 6 characters long.";
}
return null;
};
const validateEmail = (email) => {
const re =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
if (!re.test(String(email).toLowerCase())) {
return "Invalid email address.";
}
return null;
};
const handleRegister = async (e) => {
e.preventDefault();
setError(null);
if (!email || !password) {
setError("Email and password are required.");
return;
}
const emailError = validateEmail(email);
if (emailError) {
setError(emailError);
return;
}
const passwordError = validatePassword(password);
if (passwordError) {
setError(passwordError);
return;
}
setLoading(true);
try {
await createUserWithEmailAndPassword(auth, email, password);
navigate("/");
} catch (error) {
setError(error.message || "Something went wrong. Please try again.");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
<Card className="w-[350px]">
<CardHeader>
<CardTitle>Register</CardTitle>
<CardDescription>Create a new account.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleRegister}>
<div className="grid w-full items-center gap-4">
<div className="flex flex-col space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
</div>
<div className="flex flex-col space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
</div>
</form>
</CardContent>
<CardFooter className="flex-col">
<Button onClick={handleRegister} disabled={loading} className="w-full">
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Register"}
</Button>
<p className="mt-4 text-sm text-slate-600">
Already have an account?{" "}
<Link to="/login" className="text-blue-600 hover:underline">
Login
</Link>
</p>
</CardFooter>
</Card>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Download, FileText, TrendingUp, Users, DollarSign, Zap } from "lucide-react";
import StaffingCostReport from "../components/reports/StaffingCostReport";
import StaffPerformanceReport from "../components/reports/StaffPerformanceReport";
import ClientTrendsReport from "../components/reports/ClientTrendsReport";
import OperationalEfficiencyReport from "../components/reports/OperationalEfficiencyReport";
import CustomReportBuilder from "../components/reports/CustomReportBuilder";
import { useToast } from "@/components/ui/use-toast";
export default function Reports() {
const [activeTab, setActiveTab] = useState("costs");
const { toast } = useToast();
const { data: events = [] } = useQuery({
queryKey: ['events-reports'],
queryFn: () => base44.entities.Event.list(),
initialData: [],
});
const { data: staff = [] } = useQuery({
queryKey: ['staff-reports'],
queryFn: () => base44.entities.Staff.list(),
initialData: [],
});
const { data: invoices = [] } = useQuery({
queryKey: ['invoices-reports'],
queryFn: () => base44.entities.Invoice.list(),
initialData: [],
});
const handleExportAll = () => {
const data = {
events,
staff,
invoices,
generated: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `krow-full-report-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({ title: "✅ Report Exported", description: "Full report downloaded successfully" });
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Reports & Analytics</h1>
<p className="text-sm text-slate-500 mt-1">
Comprehensive insights into staffing, costs, and performance
</p>
</div>
<Button onClick={handleExportAll} className="bg-[#0A39DF]">
<Download className="w-4 h-4 mr-2" />
Export All Data
</Button>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="border-blue-200 bg-blue-50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-blue-600 font-semibold uppercase">Total Events</p>
<p className="text-2xl font-bold text-blue-700">{events.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-green-200 bg-green-50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-green-600 font-semibold uppercase">Active Staff</p>
<p className="text-2xl font-bold text-green-700">{staff.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-purple-200 bg-purple-50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-500 rounded-lg flex items-center justify-center">
<DollarSign className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-purple-600 font-semibold uppercase">Total Revenue</p>
<p className="text-2xl font-bold text-purple-700">
${invoices.reduce((sum, inv) => sum + (inv.amount || 0), 0).toLocaleString()}
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-amber-200 bg-amber-50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
<Zap className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-amber-600 font-semibold uppercase">Automation</p>
<p className="text-2xl font-bold text-amber-700">85%</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Report Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="bg-white border">
<TabsTrigger value="costs">
<DollarSign className="w-4 h-4 mr-2" />
Staffing Costs
</TabsTrigger>
<TabsTrigger value="performance">
<TrendingUp className="w-4 h-4 mr-2" />
Staff Performance
</TabsTrigger>
<TabsTrigger value="clients">
<Users className="w-4 h-4 mr-2" />
Client Trends
</TabsTrigger>
<TabsTrigger value="efficiency">
<Zap className="w-4 h-4 mr-2" />
Operational Efficiency
</TabsTrigger>
<TabsTrigger value="custom">
<FileText className="w-4 h-4 mr-2" />
Custom Reports
</TabsTrigger>
</TabsList>
<TabsContent value="costs" className="mt-6">
<StaffingCostReport events={events} invoices={invoices} />
</TabsContent>
<TabsContent value="performance" className="mt-6">
<StaffPerformanceReport staff={staff} events={events} />
</TabsContent>
<TabsContent value="clients" className="mt-6">
<ClientTrendsReport events={events} invoices={invoices} />
</TabsContent>
<TabsContent value="efficiency" className="mt-6">
<OperationalEfficiencyReport events={events} staff={staff} />
</TabsContent>
<TabsContent value="custom" className="mt-6">
<CustomReportBuilder events={events} staff={staff} invoices={invoices} />
</TabsContent>
</Tabs>
</div>
</div>
);
}

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,141 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { MapPin, Plus, Search, Building2, Edit } from "lucide-react";
import PageHeader from "../components/common/PageHeader";
export default function SectorManagement() {
const [searchTerm, setSearchTerm] = useState("");
const { data: sectors = [], isLoading } = useQuery({
queryKey: ['sectors'],
queryFn: () => base44.entities.Sector.list('-created_date'),
initialData: [],
});
const filteredSectors = sectors.filter(s =>
!searchTerm ||
s.sector_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.sector_code?.toLowerCase().includes(searchTerm.toLowerCase())
);
const getSectorColor = (index) => {
const colors = [
"from-blue-500 to-blue-700",
"from-green-500 to-green-700",
"from-purple-500 to-purple-700",
"from-orange-500 to-orange-700",
];
return colors[index % colors.length];
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<PageHeader
title="Sector Management"
subtitle={`${filteredSectors.length} sectors • Operating brands`}
actions={
<Link to={createPageUrl("AddSector")}>
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
<Plus className="w-4 h-4 mr-2" />
Add Sector
</Button>
</Link>
}
/>
{/* Search */}
<Card className="mb-6 border-slate-200">
<CardContent className="p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
placeholder="Search sectors..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</CardContent>
</Card>
{/* Sectors Grid */}
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-64 bg-slate-100 animate-pulse rounded-xl" />
))}
</div>
) : filteredSectors.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredSectors.map((sector, index) => (
<Card key={sector.id} className="border-2 border-slate-200 hover:border-[#0A39DF] hover:shadow-xl transition-all">
<CardContent className="p-6">
<div className="flex items-start gap-4 mb-4">
<div className={`w-12 h-12 bg-gradient-to-br ${getSectorColor(index)} rounded-xl flex items-center justify-center text-white font-bold`}>
<MapPin className="w-6 h-6" />
</div>
<div className="flex-1">
<h3 className="font-bold text-lg text-[#1C323E] mb-1">
{sector.sector_name}
</h3>
<p className="text-sm text-slate-500">{sector.sector_code}</p>
</div>
<Link to={createPageUrl(`EditSector?id=${sector.id}`)}>
<Button variant="ghost" size="icon" className="text-slate-400 hover:text-[#0A39DF] hover:bg-blue-50">
<Edit className="w-4 h-4" />
</Button>
</Link>
</div>
<div className="space-y-3 mb-4">
{sector.parent_enterprise_name && (
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-slate-400" />
<span className="text-sm text-slate-600">{sector.parent_enterprise_name}</span>
</div>
)}
{sector.sector_type && (
<div>
<Badge variant="outline">{sector.sector_type}</Badge>
</div>
)}
</div>
{sector.client_portfolio && sector.client_portfolio.length > 0 && (
<div className="pt-4 border-t border-slate-200">
<p className="text-sm text-slate-600">
{sector.client_portfolio.length} Partners
</p>
</div>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Card className="border-slate-200">
<CardContent className="p-12 text-center">
<MapPin className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Sectors Found</h3>
<p className="text-slate-500 mb-6">Add your first sector</p>
<Link to={createPageUrl("AddSector")}>
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
<Plus className="w-4 h-4 mr-2" />
Add First Sector
</Button>
</Link>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import React from "react";
import { Settings as SettingsIcon } from "lucide-react";
export default function Settings() {
return (
<div className="p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-3 mb-8">
<SettingsIcon className="w-8 h-8" />
<h1 className="text-3xl font-bold">Settings</h1>
</div>
<div className="text-center py-16 bg-white rounded-xl border">
<SettingsIcon className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<p className="text-slate-600">Settings page coming soon</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowRight, Sparkles } from "lucide-react";
export default function SmartScheduler() {
const navigate = useNavigate();
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen flex items-center justify-center">
<Card className="max-w-2xl w-full">
<CardContent className="p-12 text-center">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-6">
<Sparkles className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-slate-900 mb-4">
Smart Scheduling is Now Part of Orders
</h1>
<p className="text-lg text-slate-600 mb-8">
All smart assignment, automation, and scheduling features have been unified into the main Order Management view for a consistent experience.
</p>
<Button
size="lg"
onClick={() => navigate(createPageUrl("Events"))}
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
>
Go to Order Management
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
</CardContent>
</Card>
</div>
);
}

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,307 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { UserPlus, Users, LayoutGrid, List as ListIcon, Phone, MapPin, Calendar, Star } from "lucide-react";
import FilterBar from "../components/staff/FilterBar";
import StaffCard from "../components/staff/StaffCard";
import EmployeeCard from "../components/staff/EmployeeCard";
import PageHeader from "../components/common/PageHeader";
export default function StaffDirectory() {
const [searchTerm, setSearchTerm] = useState("");
const [departmentFilter, setDepartmentFilter] = useState("all");
const [locationFilter, setLocationFilter] = useState("all");
const [viewMode, setViewMode] = useState("grid"); // "grid" or "list"
const { data: user } = useQuery({
queryKey: ['current-user'],
queryFn: () => base44.auth.me(),
});
const { data: staff, isLoading } = useQuery({
queryKey: ['staff'],
queryFn: () => base44.entities.Staff.list('-created_date'),
initialData: [],
});
const { data: events } = useQuery({
queryKey: ['events-for-staff-filter'],
queryFn: () => base44.entities.Event.list(),
initialData: [],
enabled: !!user
});
const visibleStaff = React.useMemo(() => {
const userRole = user?.user_role || user?.role;
if (['admin', 'procurement'].includes(userRole)) {
return staff;
}
if (['operator', 'sector'].includes(userRole)) {
return staff;
}
if (userRole === 'vendor') {
return staff.filter(s =>
s.vendor_id === user?.id ||
s.vendor_name === user?.company_name ||
s.created_by === user?.email
);
}
if (userRole === 'client') {
const clientEvents = events.filter(e =>
e.client_email === user?.email ||
e.business_name === user?.company_name ||
e.created_by === user?.email
);
const assignedStaffIds = new Set();
clientEvents.forEach(event => {
if (event.assigned_staff) {
event.assigned_staff.forEach(assignment => {
if (assignment.staff_id) {
assignedStaffIds.add(assignment.staff_id);
}
});
}
});
return staff.filter(s => assignedStaffIds.has(s.id));
}
if (userRole === 'workforce') {
return staff;
}
return staff;
}, [staff, user, events]);
const uniqueDepartments = [...new Set(visibleStaff.map(s => s.department).filter(Boolean))];
const uniqueLocations = [...new Set(visibleStaff.map(s => s.hub_location).filter(Boolean))];
const filteredStaff = visibleStaff.filter(member => {
const matchesSearch = !searchTerm ||
member.employee_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
member.position?.toLowerCase().includes(searchTerm.toLowerCase()) ||
member.manager?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesDepartment = departmentFilter === "all" || member.department === departmentFilter;
const matchesLocation = locationFilter === "all" || member.hub_location === locationFilter;
return matchesSearch && matchesDepartment && matchesLocation;
});
const canAddStaff = ['admin', 'procurement', 'operator', 'sector', 'vendor'].includes(user?.user_role || user?.role);
const getPageTitle = () => {
const userRole = user?.user_role || user?.role;
if (userRole === 'vendor') return "My Staff Directory";
if (userRole === 'client') return "Event Staff Directory";
if (userRole === 'workforce') return "Team Directory";
return "Staff Directory";
};
const getPageSubtitle = () => {
const userRole = user?.user_role || user?.role;
if (userRole === 'vendor') return `${filteredStaff.length} of your staff members`;
if (userRole === 'client') return `${filteredStaff.length} staff assigned to your events`;
if (userRole === 'workforce') return `${filteredStaff.length} team members`;
return `${filteredStaff.length} ${filteredStaff.length === 1 ? 'member' : 'members'} found`;
};
const getCoverageColor = (percentage) => {
if (!percentage) return "bg-red-100 text-red-700";
if (percentage >= 90) return "bg-green-100 text-green-700";
if (percentage >= 50) return "bg-yellow-100 text-yellow-700";
return "bg-red-100 text-red-700";
};
return (
<div className="p-4 md:p-8">
<div className="max-w-7xl mx-auto">
<PageHeader
title={getPageTitle()}
subtitle={getPageSubtitle()}
actions={
canAddStaff ? (
<Link to={createPageUrl("AddStaff")}>
<Button className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white shadow-lg">
<UserPlus className="w-5 h-5 mr-2" />
Add New Staff
</Button>
</Link>
) : null
}
/>
<div className="mb-6">
<Card className="border-slate-200">
<CardContent className="p-4">
<div className="flex items-center gap-4 mb-4">
<div className="flex-1">
<FilterBar
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
departmentFilter={departmentFilter}
setDepartmentFilter={setDepartmentFilter}
locationFilter={locationFilter}
setLocationFilter={setLocationFilter}
departments={uniqueDepartments}
locations={uniqueLocations}
/>
</div>
<div className="flex items-center gap-1 bg-slate-100 p-1 rounded-lg">
<Button
size="sm"
variant={viewMode === "grid" ? "default" : "ghost"}
onClick={() => setViewMode("grid")}
className={viewMode === "grid" ? "bg-[#0A39DF] hover:bg-[#0A39DF]/90" : "hover:bg-white"}
>
<LayoutGrid className="w-4 h-4" />
</Button>
<Button
size="sm"
variant={viewMode === "list" ? "default" : "ghost"}
onClick={() => setViewMode("list")}
className={viewMode === "list" ? "bg-[#0A39DF] hover:bg-[#0A39DF]/90" : "hover:bg-white"}
>
<ListIcon className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(9)].map((_, i) => (
<div key={i} className="h-64 bg-slate-100 animate-pulse rounded-xl" />
))}
</div>
) : filteredStaff.length > 0 ? (
<>
{viewMode === "grid" && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredStaff.map((member) => (
<EmployeeCard key={member.id} staff={member} />
))}
</div>
)}
{viewMode === "list" && (
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50 border-b-2 border-slate-200">
<tr>
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Employee</th>
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Primary Skill</th>
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Secondary Skill</th>
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Rating</th>
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Reliability</th>
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Coverage</th>
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Cancellations</th>
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Manager</th>
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Location</th>
</tr>
</thead>
<tbody>
{filteredStaff.map((member) => (
<tr key={member.id} className="border-b border-slate-100 hover:bg-slate-50 transition-colors">
<td className="py-4 px-4">
<Link to={createPageUrl(`EditStaff?id=${member.id}`)} className="flex items-center gap-3 hover:text-[#0A39DF]">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-700 rounded-full flex items-center justify-center text-white font-bold text-sm">
{member.employee_name?.charAt(0) || '?'}
</div>
<div>
<p className="font-semibold text-[#1C323E]">{member.employee_name}</p>
{member.contact_number && (
<p className="text-xs text-slate-500 flex items-center gap-1">
<Phone className="w-3 h-3" />
{member.contact_number}
</p>
)}
</div>
</Link>
</td>
<td className="py-4 px-4 text-sm text-slate-700">{member.position || '—'}</td>
<td className="py-4 px-4 text-sm text-slate-500">{member.position_2 || '—'}</td>
<td className="py-4 px-4 text-center">
<div className="flex items-center justify-center gap-1">
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
<span className="font-semibold text-sm">
{member.rating ? member.rating.toFixed(1) : '0.0'}
</span>
</div>
</td>
<td className="py-4 px-4 text-center">
<Badge className={`${
(member.reliability_score || 0) >= 90 ? 'bg-green-100 text-green-700' :
(member.reliability_score || 0) >= 70 ? 'bg-yellow-100 text-yellow-700' :
(member.reliability_score || 0) >= 50 ? 'bg-orange-100 text-orange-700' :
'bg-red-100 text-red-700'
} font-semibold`}>
{member.reliability_score || 0}%
</Badge>
</td>
<td className="py-4 px-4 text-center">
<Badge className={`${getCoverageColor(member.shift_coverage_percentage)} font-semibold`}>
{member.shift_coverage_percentage || 0}%
</Badge>
</td>
<td className="py-4 px-4 text-center">
<Badge variant="outline" className={member.cancellation_count > 0 ? "text-red-600 border-red-300" : "text-slate-600"}>
{member.cancellation_count || 0}
</Badge>
</td>
<td className="py-4 px-4 text-sm text-slate-700">{member.manager || '—'}</td>
<td className="py-4 px-4">
{member.hub_location && (
<div className="flex items-center gap-1 text-sm text-slate-600">
<MapPin className="w-3 h-3" />
{member.hub_location}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</>
) : (
<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 Staff Members Found</h3>
<p className="text-slate-600 mb-6">
{visibleStaff.length === 0
? "No staff members available to display"
: "Try adjusting your filters"}
</p>
{canAddStaff && (
<Link to={createPageUrl("AddStaff")}>
<Button className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white">
<UserPlus className="w-4 h-4 mr-2" />
Add Staff Member
</Button>
</Link>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,197 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { CheckCircle, Circle } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import ProfileSetupStep from "../components/onboarding/ProfileSetupStep";
import DocumentUploadStep from "../components/onboarding/DocumentUploadStep";
import TrainingStep from "../components/onboarding/TrainingStep";
import CompletionStep from "../components/onboarding/CompletionStep";
const steps = [
{ id: 1, name: "Profile Setup", description: "Basic information" },
{ id: 2, name: "Documents", description: "Upload required documents" },
{ id: 3, name: "Training", description: "Complete compliance training" },
{ id: 4, name: "Complete", description: "Finish onboarding" },
];
export default function StaffOnboarding() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const [currentStep, setCurrentStep] = useState(1);
const [onboardingData, setOnboardingData] = useState({
profile: {},
documents: [],
training: { completed: [] },
});
const { data: currentUser } = useQuery({
queryKey: ['current-user-onboarding'],
queryFn: () => base44.auth.me(),
});
const createStaffMutation = useMutation({
mutationFn: (staffData) => base44.entities.Staff.create(staffData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['staff'] });
toast({
title: "✅ Onboarding Complete",
description: "Welcome to KROW! Your profile is now active.",
});
navigate(createPageUrl("WorkforceDashboard"));
},
onError: (error) => {
toast({
title: "❌ Onboarding Failed",
description: error.message,
variant: "destructive",
});
},
});
const handleNext = (stepData) => {
setOnboardingData(prev => ({
...prev,
[stepData.type]: stepData.data,
}));
if (currentStep < steps.length) {
setCurrentStep(currentStep + 1);
}
};
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
const handleComplete = () => {
const staffData = {
employee_name: onboardingData.profile.full_name,
email: onboardingData.profile.email || currentUser?.email,
phone: onboardingData.profile.phone,
address: onboardingData.profile.address,
city: onboardingData.profile.city,
position: onboardingData.profile.position,
department: onboardingData.profile.department,
hub_location: onboardingData.profile.hub_location,
employment_type: onboardingData.profile.employment_type,
english: onboardingData.profile.english_level,
certifications: onboardingData.documents.filter(d => d.type === 'certification').map(d => ({
name: d.name,
issued_date: d.issued_date,
expiry_date: d.expiry_date,
document_url: d.url,
})),
background_check_status: onboardingData.documents.some(d => d.type === 'background_check') ? 'pending' : 'not_required',
notes: `Onboarding completed: ${new Date().toISOString()}. Training modules completed: ${onboardingData.training.completed.length}`,
};
createStaffMutation.mutate(staffData);
};
const renderStep = () => {
switch (currentStep) {
case 1:
return (
<ProfileSetupStep
data={onboardingData.profile}
onNext={handleNext}
currentUser={currentUser}
/>
);
case 2:
return (
<DocumentUploadStep
data={onboardingData.documents}
onNext={handleNext}
onBack={handleBack}
/>
);
case 3:
return (
<TrainingStep
data={onboardingData.training}
onNext={handleNext}
onBack={handleBack}
/>
);
case 4:
return (
<CompletionStep
data={onboardingData}
onComplete={handleComplete}
onBack={handleBack}
isSubmitting={createStaffMutation.isPending}
/>
);
default:
return null;
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 p-4 md:p-8">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-slate-900 mb-2">
Welcome to KROW! 👋
</h1>
<p className="text-slate-600">
Let's get you set up in just a few steps
</p>
</div>
{/* Progress Steps */}
<div className="mb-8">
<div className="flex items-center justify-between">
{steps.map((step, idx) => (
<React.Fragment key={step.id}>
<div className="flex flex-col items-center flex-1">
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
currentStep > step.id
? "bg-green-500 text-white"
: currentStep === step.id
? "bg-[#0A39DF] text-white"
: "bg-slate-200 text-slate-400"
}`}>
{currentStep > step.id ? (
<CheckCircle className="w-6 h-6" />
) : (
<span className="font-bold">{step.id}</span>
)}
</div>
<p className={`text-sm font-medium mt-2 ${
currentStep >= step.id ? "text-slate-900" : "text-slate-400"
}`}>
{step.name}
</p>
<p className="text-xs text-slate-500">{step.description}</p>
</div>
{idx < steps.length - 1 && (
<div className={`flex-1 h-1 ${
currentStep > step.id ? "bg-green-500" : "bg-slate-200"
}`} />
)}
</React.Fragment>
))}
</div>
</div>
{/* Step Content */}
<Card>
<CardContent className="p-6 md:p-8">
{renderStep()}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import React from "react";
import { HelpCircle } from "lucide-react";
export default function Support() {
return (
<div className="p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-3 mb-8">
<HelpCircle className="w-8 h-8" />
<h1 className="text-3xl font-bold">Support</h1>
</div>
<div className="text-center py-16 bg-white rounded-xl border">
<HelpCircle className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<p className="text-slate-600">Support center coming soon</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,650 @@
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { DragDropContext, Draggable } from "@hello-pangea/dnd";
import { Link2, Plus, Users, Search, UserCircle, Filter, ArrowUpDown, EyeOff, Grid3x3, MoreVertical, Pin, Ruler, Palette } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import TaskCard from "../components/tasks/TaskCard";
import TaskColumn from "../components/tasks/TaskColumn";
import TaskDetailModal from "../components/tasks/TaskDetailModal";
import { useToast } from "@/components/ui/use-toast";
export default function TaskBoard() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [createDialog, setCreateDialog] = useState(false);
const [selectedTask, setSelectedTask] = useState(null);
const [selectedStatus, setSelectedStatus] = useState("pending");
const [newTask, setNewTask] = useState({
task_name: "",
description: "",
priority: "normal",
due_date: "",
progress: 0,
assigned_members: []
});
const [selectedMembers, setSelectedMembers] = useState([]);
const [searchQuery, setSearchQuery] = useState("");
const [filterPerson, setFilterPerson] = useState("all");
const [filterPriority, setFilterPriority] = useState("all");
const [sortBy, setSortBy] = useState("due_date");
const [showCompleted, setShowCompleted] = useState(true);
const [groupBy, setGroupBy] = useState("status");
const [pinnedColumns, setPinnedColumns] = useState([]);
const [itemHeight, setItemHeight] = useState("normal");
const [conditionalColoring, setConditionalColoring] = useState(true);
const { data: user } = useQuery({
queryKey: ['current-user-taskboard'],
queryFn: () => base44.auth.me(),
});
const { data: teams = [] } = useQuery({
queryKey: ['teams'],
queryFn: () => base44.entities.Team.list(),
initialData: [],
});
const { data: teamMembers = [] } = useQuery({
queryKey: ['team-members'],
queryFn: () => base44.entities.TeamMember.list(),
initialData: [],
});
const { data: tasks = [] } = useQuery({
queryKey: ['tasks'],
queryFn: () => base44.entities.Task.list(),
initialData: [],
});
const userTeam = teams.find(t => t.owner_id === user?.id) || teams[0];
let teamTasks = tasks.filter(t => t.team_id === userTeam?.id);
// Apply filters
if (searchQuery) {
teamTasks = teamTasks.filter(t =>
t.task_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.description?.toLowerCase().includes(searchQuery.toLowerCase())
);
}
if (filterPerson !== "all") {
teamTasks = teamTasks.filter(t =>
t.assigned_members?.some(m => m.member_id === filterPerson)
);
}
if (filterPriority !== "all") {
teamTasks = teamTasks.filter(t => t.priority === filterPriority);
}
if (!showCompleted) {
teamTasks = teamTasks.filter(t => t.status !== "completed");
}
const currentTeamMembers = teamMembers.filter(m => m.team_id === userTeam?.id);
const leadMembers = currentTeamMembers.filter(m => m.role === 'admin' || m.role === 'manager');
const regularMembers = currentTeamMembers.filter(m => m.role === 'member');
// Get unique departments from team members
const departments = [...new Set(currentTeamMembers.map(m => m.department).filter(Boolean))];
const sortTasks = (tasks) => {
return [...tasks].sort((a, b) => {
switch (sortBy) {
case "due_date":
return new Date(a.due_date || '9999-12-31') - new Date(b.due_date || '9999-12-31');
case "priority":
const priorityOrder = { high: 0, normal: 1, low: 2 };
return (priorityOrder[a.priority] || 1) - (priorityOrder[b.priority] || 1);
case "created_date":
return new Date(b.created_date || 0) - new Date(a.created_date || 0);
case "task_name":
return (a.task_name || '').localeCompare(b.task_name || '');
default:
return (a.order_index || 0) - (b.order_index || 0);
}
});
};
const tasksByStatus = useMemo(() => ({
pending: sortTasks(teamTasks.filter(t => t.status === 'pending')),
in_progress: sortTasks(teamTasks.filter(t => t.status === 'in_progress')),
on_hold: sortTasks(teamTasks.filter(t => t.status === 'on_hold')),
completed: sortTasks(teamTasks.filter(t => t.status === 'completed')),
}), [teamTasks, sortBy]);
const overallProgress = useMemo(() => {
if (teamTasks.length === 0) return 0;
const totalProgress = teamTasks.reduce((sum, t) => sum + (t.progress || 0), 0);
return Math.round(totalProgress / teamTasks.length);
}, [teamTasks]);
const createTaskMutation = useMutation({
mutationFn: (taskData) => base44.entities.Task.create(taskData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setCreateDialog(false);
setNewTask({
task_name: "",
description: "",
priority: "normal",
due_date: "",
progress: 0,
assigned_members: []
});
setSelectedMembers([]);
toast({
title: "✅ Task Created",
description: "New task added to the board",
});
},
});
const updateTaskMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Task.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
const handleDragEnd = (result) => {
if (!result.destination) return;
const { source, destination, draggableId } = result;
if (source.droppableId === destination.droppableId && source.index === destination.index) {
return;
}
const task = teamTasks.find(t => t.id === draggableId);
if (!task) return;
const newStatus = destination.droppableId;
updateTaskMutation.mutate({
id: task.id,
data: {
...task,
status: newStatus,
order_index: destination.index
}
});
};
const handleCreateTask = () => {
if (!newTask.task_name.trim()) {
toast({
title: "Task name required",
variant: "destructive",
});
return;
}
createTaskMutation.mutate({
...newTask,
team_id: userTeam?.id,
status: selectedStatus,
order_index: tasksByStatus[selectedStatus]?.length || 0,
assigned_members: selectedMembers.map(m => ({
member_id: m.id,
member_name: m.member_name,
avatar_url: m.avatar_url
})),
assigned_department: selectedMembers.length > 0 && selectedMembers[0].department ? selectedMembers[0].department : null
});
};
return (
<div className="p-6 bg-slate-50 min-h-screen">
<div className="max-w-[1800px] mx-auto">
{/* Header */}
<div className="bg-white rounded-xl p-6 mb-6 shadow-sm border border-slate-200">
{/* Toolbar */}
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-slate-200">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<UserCircle className="w-4 h-4" />
Person
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuItem onClick={() => setFilterPerson("all")}>
All People
</DropdownMenuItem>
<DropdownMenuSeparator />
{currentTeamMembers.map((member) => (
<DropdownMenuItem
key={member.id}
onClick={() => setFilterPerson(member.id)}
>
{member.member_name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Filter className="w-4 h-4" />
Filter
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Priority</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setFilterPriority("all")}>All</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFilterPriority("high")}>High</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFilterPriority("normal")}>Normal</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFilterPriority("low")}>Low</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<ArrowUpDown className="w-4 h-4" />
Sort
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setSortBy("due_date")}>Due Date</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("priority")}>Priority</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("created_date")}>Created Date</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("task_name")}>Name</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() => setShowCompleted(!showCompleted)}
>
<EyeOff className="w-4 h-4" />
Hide
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Grid3x3 className="w-4 h-4" />
Group by
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setGroupBy("status")}>Status</DropdownMenuItem>
<DropdownMenuItem onClick={() => setGroupBy("priority")}>Priority</DropdownMenuItem>
<DropdownMenuItem onClick={() => setGroupBy("assigned")}>Assigned To</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => setPinnedColumns(pinnedColumns.length > 0 ? [] : ['pending'])}>
<Pin className="w-4 h-4 mr-2" />
Pin columns
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Item height</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setItemHeight("compact")}>
<Ruler className="w-4 h-4 mr-2" />
Compact
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setItemHeight("normal")}>
<Ruler className="w-4 h-4 mr-2" />
Normal
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setItemHeight("comfortable")}>
<Ruler className="w-4 h-4 mr-2" />
Comfortable
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setConditionalColoring(!conditionalColoring)}>
<Palette className="w-4 h-4 mr-2" />
Conditional coloring
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-2xl font-bold text-slate-900 mb-2">Task Board</h1>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-600">Lead</span>
<div className="flex -space-x-2">
{leadMembers.slice(0, 3).map((member, idx) => (
<Avatar key={idx} className="w-8 h-8 border-2 border-white">
<img
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
alt={member.member_name}
className="w-full h-full object-cover"
/>
</Avatar>
))}
{leadMembers.length > 3 && (
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-semibold text-slate-600">
+{leadMembers.length - 3}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-600">Team</span>
<div className="flex -space-x-2">
{regularMembers.slice(0, 3).map((member, idx) => (
<Avatar key={idx} className="w-8 h-8 border-2 border-white">
<img
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
alt={member.member_name}
className="w-full h-full object-cover"
/>
</Avatar>
))}
{regularMembers.length > 3 && (
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-semibold text-slate-600">
+{regularMembers.length - 3}
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" className="gap-2 bg-white hover:bg-slate-50 border border-slate-300 text-slate-700 font-medium">
<Link2 className="w-4 h-4" />
Share
</Button>
<Button
onClick={() => {
setSelectedStatus("pending");
setCreateDialog(true);
}}
className="gap-2 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold shadow-md"
>
<Plus className="w-5 h-5" />
Create Task
</Button>
</div>
</div>
{/* Overall Progress */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex-1 h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[#0A39DF] to-blue-600 transition-all"
style={{ width: `${overallProgress}%` }}
/>
</div>
<span className="text-sm font-bold text-slate-900 ml-4">{overallProgress}%</span>
</div>
</div>
</div>
{/* Kanban Board */}
<DragDropContext onDragEnd={handleDragEnd}>
<div className="flex gap-4 overflow-x-auto pb-4">
{['pending', 'in_progress', 'on_hold', 'completed'].map((status) => (
<TaskColumn
key={status}
status={status}
tasks={tasksByStatus[status]}
onAddTask={(status) => {
setSelectedStatus(status);
setCreateDialog(true);
}}
>
{tasksByStatus[status].map((task, index) => (
<Draggable key={task.id} draggableId={task.id} index={index}>
{(provided) => (
<TaskCard
task={task}
provided={provided}
onClick={() => setSelectedTask(task)}
itemHeight={itemHeight}
conditionalColoring={conditionalColoring}
/>
)}
</Draggable>
))}
</TaskColumn>
))}
</div>
</DragDropContext>
{teamTasks.length === 0 && (
<div className="text-center py-16 bg-white rounded-xl border-2 border-dashed border-slate-300">
<div className="w-16 h-16 mx-auto mb-4 bg-slate-100 rounded-xl flex items-center justify-center">
<Plus className="w-8 h-8 text-slate-400" />
</div>
<h3 className="font-bold text-xl text-slate-900 mb-2">No tasks yet</h3>
<p className="text-slate-600 mb-5">Create your first task to get started</p>
<Button onClick={() => setCreateDialog(true)} className="bg-[#0A39DF]">
<Plus className="w-4 h-4 mr-2" />
Create Task
</Button>
</div>
)}
</div>
{/* Create Task Dialog */}
<Dialog open={createDialog} onOpenChange={setCreateDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create New Task</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label>Task Name *</Label>
<Input
value={newTask.task_name}
onChange={(e) => setNewTask({ ...newTask, task_name: e.target.value })}
placeholder="e.g., Website Design"
className="mt-1"
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
placeholder="Task details..."
rows={3}
className="mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Priority</Label>
<Select value={newTask.priority} onValueChange={(val) => setNewTask({ ...newTask, priority: val })}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Due Date</Label>
<Input
type="date"
value={newTask.due_date}
onChange={(e) => setNewTask({ ...newTask, due_date: e.target.value })}
className="mt-1"
/>
</div>
</div>
<div>
<Label>Initial Progress (%)</Label>
<Input
type="number"
min="0"
max="100"
value={newTask.progress}
onChange={(e) => setNewTask({ ...newTask, progress: parseInt(e.target.value) || 0 })}
className="mt-1"
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<Label>Assign Team Members</Label>
{departments.length > 0 && (
<Select onValueChange={(dept) => {
const deptMembers = currentTeamMembers.filter(m => m.department === dept);
setSelectedMembers(deptMembers);
}}>
<SelectTrigger className="w-56">
<SelectValue placeholder="Assign entire department" />
</SelectTrigger>
<SelectContent>
{departments.map((dept) => {
const count = currentTeamMembers.filter(m => m.department === dept).length;
return (
<SelectItem key={dept} value={dept}>
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
{dept} ({count} members)
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
)}
</div>
<div className="mt-2 space-y-2">
{currentTeamMembers.length === 0 ? (
<p className="text-sm text-slate-500">No team members available</p>
) : (
<div className="max-h-48 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
{currentTeamMembers.map((member) => {
const isSelected = selectedMembers.some(m => m.id === member.id);
return (
<div
key={member.id}
onClick={() => {
if (isSelected) {
setSelectedMembers(selectedMembers.filter(m => m.id !== member.id));
} else {
setSelectedMembers([...selectedMembers, member]);
}
}}
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-all ${
isSelected ? 'bg-blue-50 border-2 border-[#0A39DF]' : 'hover:bg-slate-50 border-2 border-transparent'
}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="w-4 h-4 rounded text-[#0A39DF] focus:ring-[#0A39DF]"
/>
<Avatar className="w-8 h-8">
<img
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
alt={member.member_name}
className="w-full h-full object-cover"
/>
</Avatar>
<div className="flex-1">
<p className="text-sm font-medium text-slate-900">{member.member_name}</p>
<p className="text-xs text-slate-500">
{member.department ? `${member.department}` : ''}{member.role || 'Member'}
</p>
</div>
</div>
);
})}
</div>
)}
{selectedMembers.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-slate-50 rounded-lg">
{selectedMembers.map((member) => (
<Badge key={member.id} className="bg-[#0A39DF] text-white flex items-center gap-1">
{member.member_name}
<button
onClick={(e) => {
e.stopPropagation();
setSelectedMembers(selectedMembers.filter(m => m.id !== member.id));
}}
className="ml-1 hover:bg-white/20 rounded-full p-0.5"
>
×
</button>
</Badge>
))}
</div>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialog(false)}>
Cancel
</Button>
<Button
onClick={handleCreateTask}
disabled={createTaskMutation.isPending}
className="bg-[#0A39DF]"
>
Create Task
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Task Detail Modal with Comments */}
<TaskDetailModal
task={selectedTask}
open={!!selectedTask}
onClose={() => setSelectedTask(null)}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

@@ -0,0 +1,596 @@
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
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 UserPermissionsModal from "../components/permissions/UserPermissionsModal";
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() {
const [showInviteDialog, setShowInviteDialog] = useState(false);
const [activeLayer, setActiveLayer] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
const [inviteData, setInviteData] = useState({
email: "",
full_name: "",
user_role: "workforce",
company_name: "",
phone: "",
department: ""
});
const [selectedUser, setSelectedUser] = useState(null);
const [showPermissionsModal, setShowPermissionsModal] = useState(false);
const [showUserDetailModal, setShowUserDetailModal] = useState(false);
const queryClient = useQueryClient();
const { toast } = useToast();
const { data: users = [] } = useQuery({
queryKey: ['all-users'],
queryFn: async () => {
const allUsers = await base44.entities.User.list('-created_date');
return allUsers;
},
initialData: [],
});
const { data: currentUser } = useQuery({
queryKey: ['current-user'],
queryFn: () => base44.auth.me(),
});
const updateUserMutation = useMutation({
mutationFn: ({ userId, data }) => base44.entities.User.update(userId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['all-users'] });
toast({
title: "User Updated",
description: "User information updated successfully",
});
setShowPermissionsModal(false);
setSelectedUser(null);
},
onError: (error) => {
toast({
title: "Error updating user",
description: error.message || "Failed to update user information.",
variant: "destructive",
});
}
});
// 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 () => {
if (!inviteData.email || !inviteData.full_name) {
toast({
title: "Missing Information",
description: "Please fill in email and full name",
variant: "destructive"
});
return;
}
toast({
title: "User Invited",
description: `Invitation sent to ${inviteData.email}. They will receive setup instructions via email.`,
});
setShowInviteDialog(false);
setInviteData({
email: "",
full_name: "",
user_role: "workforce",
company_name: "",
phone: "",
department: ""
});
};
const handleEditPermissions = (user) => {
setSelectedUser(user);
setShowPermissionsModal(true);
};
const handleViewUser = (user) => {
setSelectedUser(user);
setShowUserDetailModal(true);
};
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") {
return (
<div className="p-8 text-center">
<Shield className="w-16 h-16 mx-auto text-red-500 mb-4" />
<h2 className="text-2xl font-bold text-slate-900 mb-2">Access Denied</h2>
<p className="text-slate-600">Only administrators can access user management.</p>
</div>
);
}
const sampleAvatar = "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=400&fit=crop";
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
<div>
<h1 className="text-3xl font-bold text-slate-900">User Management</h1>
<p className="text-slate-500 mt-1">Manage users across all ecosystem layers</p>
</div>
<Button
onClick={() => setShowInviteDialog(true)}
className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] shadow-lg"
>
<UserPlus className="w-4 h-4 mr-2" />
Invite User
</Button>
</div>
{/* Layer Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-3 mb-6">
{LAYERS.map((layer) => {
const Icon = layer.icon;
const isActive = activeLayer === layer.id;
return (
<button
key={layer.id}
onClick={() => setActiveLayer(layer.id)}
className={`p-4 rounded-xl border-2 transition-all text-center ${
isActive
? 'border-[#0A39DF] bg-blue-50 shadow-md scale-105'
: 'border-slate-200 bg-white hover:border-slate-300 hover:shadow-sm'
}`}
>
<div className={`w-10 h-10 mx-auto rounded-lg ${layer.color} flex items-center justify-center mb-2`}>
<Icon className="w-5 h-5 text-white" />
</div>
<p className="text-2xl font-bold text-slate-900">{layerStats[layer.id]}</p>
<p className="text-xs text-slate-500 truncate">{layer.name}</p>
</button>
);
})}
</div>
{/* Search and Filter */}
<Card className="mb-6 border-slate-200 shadow-sm">
<CardContent className="p-4">
<div className="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 by name, email, or company..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 border-slate-300"
/>
</div>
<div className="flex gap-2">
<Select value={activeLayer} onValueChange={setActiveLayer}>
<SelectTrigger className="w-[180px]">
<Filter className="w-4 h-4 mr-2" />
<SelectValue placeholder="Filter by layer" />
</SelectTrigger>
<SelectContent>
{LAYERS.map((layer) => (
<SelectItem key={layer.id} value={layer.id}>
{layer.name} ({layerStats[layer.id]})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</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 */}
<Dialog open={showInviteDialog} onOpenChange={setShowInviteDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="w-5 h-5 text-[#0A39DF]" />
Invite New User
</DialogTitle>
</DialogHeader>
<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>
<Label>Full Name *</Label>
<Input
value={inviteData.full_name}
onChange={(e) => setInviteData({ ...inviteData, full_name: e.target.value })}
placeholder="John Doe"
className="mt-1"
/>
</div>
<div>
<Label>Email *</Label>
<Input
type="email"
value={inviteData.email}
onChange={(e) => setInviteData({ ...inviteData, email: e.target.value })}
placeholder="john@example.com"
className="mt-1"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Phone</Label>
<Input
value={inviteData.phone}
onChange={(e) => setInviteData({ ...inviteData, phone: e.target.value })}
placeholder="(555) 123-4567"
className="mt-1"
/>
</div>
<div>
<Label>Company Name</Label>
<Input
value={inviteData.company_name}
onChange={(e) => setInviteData({ ...inviteData, company_name: e.target.value })}
placeholder="Acme Corp"
className="mt-1"
/>
</div>
</div>
<div>
<Label>Department</Label>
<Input
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>
<DialogFooter>
<Button variant="outline" onClick={() => setShowInviteDialog(false)}>
Cancel
</Button>
<Button onClick={handleInviteUser} className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E]">
<Mail className="w-4 h-4 mr-2" />
Send Invitation
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* User Detail Modal */}
<Dialog open={showUserDetailModal} onOpenChange={setShowUserDetailModal}>
<DialogContent className="max-w-lg">
{selectedUser && (
<>
<DialogHeader>
<DialogTitle>User Details</DialogTitle>
</DialogHeader>
<div className="py-4">
<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>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,510 @@
import React, { useState, useEffect } from "react"; // Added useEffect
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; // Added CardHeader, CardTitle
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { ArrowLeft, FileText, Shield, CheckCircle2, Clock, Eye } from "lucide-react";
import PageHeader from "../components/common/PageHeader";
import DocumentViewer from "../components/vendor/DocumentViewer";
import { useToast } from "@/components/ui/use-toast";
const ONBOARDING_DOCUMENTS = [
{
id: "nda",
name: "Confidentiality & Non-Disclosure Agreement",
type: "NDA",
url: "https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/99cd2ac5c_LegendaryEventStaffingFOODBUYVendorNDA.pdf",
description: "Confidential information protection agreement between Foodbuy and Legendary Event Staffing",
required: true,
icon: Shield,
color: "from-purple-500 to-purple-600"
},
{
id: "contract",
name: "Foodbuy Temporary Staffing Agreement",
type: "Contract",
url: "https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/2c22905a2_FoodbuyDraftContract.pdf",
description: "Standard temporary staffing service agreement with Foodbuy and Compass Group",
required: true,
icon: FileText,
color: "from-blue-500 to-blue-600"
},
{
id: "service-agreement",
name: "Vendor Service Standards Agreement",
type: "Service Agreement",
url: "https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/e57a799cf_image.png",
description: "Service standards including fill rate, response time, and compliance requirements",
required: true,
icon: Shield,
color: "from-emerald-500 to-emerald-600"
}
];
export default function VendorDocumentReview() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const [activeDoc, setActiveDoc] = useState("nda"); // Changed from "contract" to "nda"
const { data: user } = useQuery({
queryKey: ['current-user-doc-review'],
queryFn: () => base44.auth.me(),
});
const vendorName = user?.company_name || "Vendor";
const vendorId = user?.id;
const [attestations, setAttestations] = useState({
background_checks: false,
i9_verification: false,
wage_compliance: false,
general_compliance: false
});
// Effect to load initial attestations from user data once available
useEffect(() => {
if (user?.attestations) {
setAttestations(user.attestations);
}
}, [user]); // Run when user object changes
// Fetch existing document reviews
const { data: reviews = [] } = useQuery({
queryKey: ['vendor-document-reviews', vendorId],
queryFn: async () => {
if (!vendorId) return [];
return await base44.entities.VendorDocumentReview.filter({ vendor_id: vendorId });
},
enabled: !!vendorId,
initialData: [],
});
// Save/update review mutation
const saveReviewMutation = useMutation({
mutationFn: async ({ documentId, reviewData, acknowledged = false }) => {
const doc = ONBOARDING_DOCUMENTS.find(d => d.id === documentId);
if (!doc) return;
const existingReview = reviews.find(r =>
r.vendor_id === vendorId && r.document_type === doc.type
);
const fullReviewData = {
vendor_id: vendorId,
vendor_name: vendorName,
document_type: doc.type,
document_url: doc.url,
document_name: doc.name,
review_notes: reviewData.notes || "",
time_spent_minutes: reviewData.reviewTime || 0,
annotations: reviewData.annotations || [],
bookmarks: reviewData.bookmarks || [], // Added bookmarks to reviewData
reviewed: true,
reviewed_date: new Date().toISOString(),
acknowledged: acknowledged,
acknowledged_date: acknowledged ? new Date().toISOString() : existingReview?.acknowledged_date,
};
if (existingReview) {
return await base44.entities.VendorDocumentReview.update(existingReview.id, fullReviewData);
} else {
return await base44.entities.VendorDocumentReview.create(fullReviewData);
}
},
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['vendor-document-reviews'] });
// Show success toast
if (variables.acknowledged) {
toast({
title: "✅ Document Acknowledged",
description: `${ONBOARDING_DOCUMENTS.find(d => d.id === variables.documentId)?.name} has been acknowledged`,
});
} else {
const annotationCount = variables.reviewData?.annotations?.length || 0;
const bookmarkCount = variables.reviewData?.bookmarks?.length || 0;
toast({
title: "✅ Progress Saved",
description: `Saved ${annotationCount} annotations and ${bookmarkCount} bookmarks`,
});
}
},
onError: (error) => {
toast({
title: "❌ Save Failed",
description: "Failed to save. Please try again.",
variant: "destructive",
});
},
});
const handleSaveNotes = (reviewData) => {
saveReviewMutation.mutate({
documentId: activeDoc,
reviewData,
acknowledged: false
});
};
const handleAcknowledge = (reviewData) => {
saveReviewMutation.mutate({
documentId: activeDoc,
reviewData,
acknowledged: true
});
};
const getDocumentReview = (documentId) => {
const doc = ONBOARDING_DOCUMENTS.find(d => d.id === documentId);
return reviews.find(r => r.document_type === doc?.type);
};
const handleAttestationChange = (field, value) => {
setAttestations(prev => ({ ...prev, [field]: value }));
};
const handleSaveAttestations = async () => {
try {
await base44.auth.updateMe({
attestations: attestations,
attestation_date: new Date().toISOString()
});
// Invalidate the user query to refetch updated attestations
queryClient.invalidateQueries({ queryKey: ['current-user-doc-review'] });
toast({
title: "✅ Attestations Saved",
description: "Your compliance attestations have been recorded",
});
} catch (error) {
console.error("Failed to save attestations:", error);
toast({
title: "❌ Save Failed",
description: "Failed to save attestations. Please try again.",
variant: "destructive",
});
}
};
const allRequiredAcknowledged = ONBOARDING_DOCUMENTS
.filter(doc => doc.required)
.every(doc => {
const review = getDocumentReview(doc.id);
return review?.acknowledged;
});
const acknowledgedCount = ONBOARDING_DOCUMENTS.filter(doc => {
const review = getDocumentReview(doc.id);
return review?.acknowledged;
}).length;
const allAttestationsAcknowledged = Object.values(attestations).every(val => val === true);
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-[1800px] mx-auto">
<div className="mb-6">
<Button
variant="ghost"
onClick={() => navigate(createPageUrl("VendorOnboarding"))}
className="mb-4 hover:bg-slate-100"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Onboarding
</Button>
<PageHeader
title="Document Review Center"
subtitle={`Review and acknowledge onboarding documents • ${acknowledgedCount}/${ONBOARDING_DOCUMENTS.length} completed`}
/>
</div>
{/* Progress Overview */}
<Card className="border-slate-200 mb-6">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-slate-900">Onboarding Documents Progress</h3>
<Badge className={allRequiredAcknowledged ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700"}>
{acknowledgedCount}/{ONBOARDING_DOCUMENTS.length} Acknowledged
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{ONBOARDING_DOCUMENTS.map(doc => {
const review = getDocumentReview(doc.id);
const Icon = doc.icon;
return (
<div
key={doc.id}
onClick={() => setActiveDoc(doc.id)}
className={`p-4 border-2 rounded-lg cursor-pointer transition-all hover:shadow-md ${
activeDoc === doc.id
? 'border-[#0A39DF] bg-blue-50/50'
: 'border-slate-200 hover:border-300'
}`}
>
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg bg-gradient-to-br ${doc.color} flex items-center justify-center flex-shrink-0`}>
<Icon className="w-5 h-5 text-white" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm text-slate-900 mb-1">{doc.name}</h4>
<p className="text-xs text-slate-600 mb-2">{doc.description}</p>
<div className="flex items-center gap-2 flex-wrap">
{doc.required && (
<Badge variant="outline" className="text-[10px] bg-red-50 text-red-700 border-red-200">
Required
</Badge>
)}
{review?.acknowledged ? (
<Badge className="bg-green-100 text-green-700 text-[10px]">
<CheckCircle2 className="w-3 h-3 mr-1" />
Acknowledged
</Badge>
) : review?.reviewed ? (
<Badge className="bg-blue-100 text-blue-700 text-[10px]">
<Eye className="w-3 h-3 mr-1" />
Reviewed
</Badge>
) : (
<Badge variant="outline" className="text-[10px]">
Not Started
</Badge>
)}
{review && review.time_spent_minutes > 0 && (
<span className="text-[10px] text-slate-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{review.time_spent_minutes} min
</span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Compliance Attestations Section */}
<Card className="border-slate-200 mb-6">
<CardHeader className="bg-gradient-to-br from-green-50 to-white border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-xl flex items-center gap-2">
<Shield className="w-6 h-6 text-green-600" />
Compliance Attestations
</CardTitle>
<p className="text-sm text-slate-500 mt-1">
Review and attest to compliance requirements
</p>
</div>
{allAttestationsAcknowledged && (
<Badge className="bg-green-100 text-green-700">
<CheckCircle2 className="w-4 h-4 mr-1" />
All Attested
</Badge>
)}
</div>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{/* Background Checks Attestation */}
<div className="p-4 bg-slate-50 rounded-lg border-2 border-slate-200 hover:border-blue-300 transition-all">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={attestations.background_checks}
onChange={(e) => handleAttestationChange('background_checks', e.target.checked)}
className="mt-1 w-5 h-5 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<div className="flex-1">
<h4 className="font-semibold text-slate-900 mb-1">
Background Checks Required
</h4>
<p className="text-sm text-slate-600">
I attest that all workforce members assigned to Compass locations will have completed
background checks as required by the Service Contract Act and specified in Attachment "C".
Background checks will be current and meet all federal, state, and local requirements.
</p>
</div>
</label>
</div>
{/* I-9 Verification Attestation */}
<div className="p-4 bg-slate-50 rounded-lg border-2 border-slate-200 hover:border-blue-300 transition-all">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={attestations.i9_verification}
onChange={(e) => handleAttestationChange('i9_verification', e.target.checked)}
className="mt-1 w-5 h-5 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<div className="flex-1">
<h4 className="font-semibold text-slate-900 mb-1">
I-9 Employment Eligibility Verification
</h4>
<p className="text-sm text-slate-600">
I attest that we comply with the Immigration Reform and Control Act of 1986 (IRCA) by
examining specified documents to verify identity and work eligibility using Form I-9
for all personnel. We also use E-Verify to determine eligibility to work in the United States.
</p>
</div>
</label>
</div>
{/* Wage Compliance Attestation */}
<div className="p-4 bg-slate-50 rounded-lg border-2 border-slate-200 hover:border-blue-300 transition-all">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={attestations.wage_compliance}
onChange={(e) => handleAttestationChange('wage_compliance', e.target.checked)}
className="mt-1 w-5 h-5 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<div className="flex-1">
<h4 className="font-semibold text-slate-900 mb-1">
Wage & Hour Compliance
</h4>
<p className="text-sm text-slate-600">
I attest that all rates comply with local minimum wage laws and Service Contract Act (SCA)
wage determinations where applicable. All pricing is competitive within market standards.
We will pay Assigned Employees weekly for hours worked in accordance with all applicable laws,
including proper overtime calculation.
</p>
</div>
</label>
</div>
{/* General Compliance Attestation */}
<div className="p-4 bg-slate-50 rounded-lg border-2 border-slate-200 hover:border-blue-300 transition-all">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={attestations.general_compliance}
onChange={(e) => handleAttestationChange('general_compliance', e.target.checked)}
className="mt-1 w-5 h-5 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<div className="flex-1">
<h4 className="font-semibold text-slate-900 mb-1">
General Compliance & Service Standards
</h4>
<p className="text-sm text-slate-600">
I attest that we will maintain a 95% fill rate for all orders, respond to order requests
within 2 hours, ensure all staff arrive on-time and in proper uniform, maintain current
W-9 forms and Certificates of Insurance with minimum $1M general liability, and comply with
all laws, rules, regulations, and ordinances applicable to services provided.
</p>
</div>
</label>
</div>
</div>
{/* Save Attestations Button */}
<div className="mt-6 flex justify-end">
<Button
onClick={handleSaveAttestations}
disabled={!allAttestationsAcknowledged}
className="bg-green-600 hover:bg-green-700 text-white"
>
<CheckCircle2 className="w-4 h-4 mr-2" />
Save Attestations
</Button>
</div>
<div className="mt-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
<p className="text-xs text-slate-700">
<span className="font-semibold text-blue-900">Legal Notice:</span> By checking these boxes,
you are legally attesting that your organization complies with all stated requirements.
False attestation may result in contract termination and legal action.
</p>
</div>
</CardContent>
</Card>
{/* Document Tabs */}
<Tabs value={activeDoc} onValueChange={setActiveDoc}>
<TabsList className="bg-white border border-slate-200 mb-6">
{ONBOARDING_DOCUMENTS.map(doc => {
const review = getDocumentReview(doc.id);
const annotationCount = review?.annotations?.length || 0;
return (
<TabsTrigger
key={doc.id}
value={doc.id}
className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white relative"
>
<div className="flex items-center gap-2">
<span>{doc.name.split(' ')[0]}</span>
{review?.acknowledged && (
<CheckCircle2 className="w-4 h-4 text-green-600 data-[state=active]:text-white" />
)}
{annotationCount > 0 && (
<Badge
variant="outline"
className="ml-1 h-5 px-1.5 text-[10px] bg-purple-100 text-purple-700 border-purple-300"
>
{annotationCount}
</Badge>
)}
</div>
</TabsTrigger>
);
})}
</TabsList>
{ONBOARDING_DOCUMENTS.map(doc => {
const review = getDocumentReview(doc.id);
return (
<TabsContent key={doc.id} value={doc.id}>
<DocumentViewer
documentUrl={doc.url}
documentName={doc.name}
documentType={doc.type}
onSaveNotes={handleSaveNotes}
onAcknowledge={handleAcknowledge}
initialNotes={review?.review_notes || ""}
isAcknowledged={review?.acknowledged || false}
timeSpent={review?.time_spent_minutes || 0}
initialAnnotations={review?.annotations || []}
/>
</TabsContent>
);
})}
</Tabs>
{/* Action Footer */}
{allRequiredAcknowledged && allAttestationsAcknowledged && (
<Card className="border-green-200 bg-green-50">
<CardContent className="p-6 text-center">
<CheckCircle2 className="w-12 h-12 text-green-600 mx-auto mb-3" />
<h3 className="text-lg font-semibold text-green-900 mb-2">
All Onboarding Requirements Met!
</h3>
<p className="text-sm text-green-700 mb-4">
You've successfully reviewed all required documents and attested to compliance requirements.
</p>
<Button
onClick={() => navigate(createPageUrl("VendorOnboarding"))}
className="bg-green-600 hover:bg-green-700 text-white"
>
Continue Onboarding Process
</Button>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { FileText, Download, DollarSign, TrendingUp, Calendar, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function VendorInvoices() {
const invoices = [
{ id: "INV-V001", client: "Tech Corp", amount: 12400, status: "Paid", date: "2025-01-10", services: "Event Staffing - 12 staff x 8 hours" },
{ id: "INV-V002", client: "Premier Events", amount: 8950, status: "Pending", date: "2025-01-15", services: "Catering Staff - 8 staff x 6 hours" },
{ id: "INV-V003", client: "Corporate Solutions", amount: 15200, status: "Sent", date: "2025-01-18", services: "Full Service - 20 staff x 10 hours" },
];
const stats = {
totalRevenue: invoices.reduce((sum, inv) => sum + inv.amount, 0),
paid: invoices.filter(i => i.status === "Paid").reduce((sum, i) => sum + i.amount, 0),
pending: invoices.filter(i => i.status === "Pending" || i.status === "Sent").reduce((sum, i) => sum + i.amount, 0),
count: invoices.length,
};
const getStatusColor = (status) => {
const colors = {
'Paid': 'bg-green-100 text-green-700',
'Pending': 'bg-yellow-100 text-yellow-700',
'Sent': 'bg-blue-100 text-blue-700',
'Overdue': 'bg-red-100 text-red-700',
};
return colors[status] || 'bg-slate-100 text-slate-700';
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold text-[#1C323E]">Invoices</h1>
<p className="text-slate-500 mt-1">Track your billing and payments</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card className="border-slate-200">
<CardContent className="p-6">
<DollarSign className="w-8 h-8 text-[#0A39DF] mb-2" />
<p className="text-sm text-slate-500">Total Revenue</p>
<p className="text-3xl font-bold text-[#1C323E]">${stats.totalRevenue.toLocaleString()}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<CheckCircle2 className="w-8 h-8 text-green-600 mb-2" />
<p className="text-sm text-slate-500">Paid</p>
<p className="text-3xl font-bold text-green-600">${stats.paid.toLocaleString()}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<TrendingUp className="w-8 h-8 text-yellow-600 mb-2" />
<p className="text-sm text-slate-500">Pending</p>
<p className="text-3xl font-bold text-yellow-600">${stats.pending.toLocaleString()}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<FileText className="w-8 h-8 text-blue-600 mb-2" />
<p className="text-sm text-slate-500">Total Invoices</p>
<p className="text-3xl font-bold text-blue-600">{stats.count}</p>
</CardContent>
</Card>
</div>
{/* Invoices List */}
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle>All Invoices</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{invoices.map((invoice) => (
<div key={invoice.id} className="p-6 bg-white border-2 border-slate-200 rounded-xl hover:border-[#0A39DF] transition-all">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold text-[#1C323E]">{invoice.id}</h3>
<Badge className={getStatusColor(invoice.status)}>
{invoice.status}
</Badge>
</div>
<p className="text-sm font-semibold text-slate-700">{invoice.client}</p>
<p className="text-sm text-slate-500 mt-1">{invoice.services}</p>
<div className="flex items-center gap-2 mt-2 text-xs text-slate-500">
<Calendar className="w-3 h-3" />
{invoice.date}
</div>
</div>
<div className="text-right">
<p className="text-3xl font-bold text-[#0A39DF] mb-3">${invoice.amount.toLocaleString()}</p>
<Button variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" />
Download
</Button>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,998 @@
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Textarea } from "@/components/ui/textarea";
import { Search, MapPin, Users, Star, DollarSign, TrendingUp, MessageSquare, CheckCircle, Award, Filter, Grid, List, Phone, Mail, Building2, Zap, ArrowRight, ChevronDown, ChevronUp, UserCheck, Briefcase, Shield, Crown, X, Edit2, Clock, Target, Handshake } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
export default function VendorMarketplace() {
const navigate = useNavigate();
const { toast } = useToast();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [regionFilter, setRegionFilter] = useState("all");
const [categoryFilter, setCategoryFilter] = useState("all");
const [sortBy, setSortBy] = useState("rating");
const [viewMode, setViewMode] = useState("grid");
const [contactModal, setContactModal] = useState({ open: false, vendor: null });
const [message, setMessage] = useState("");
const [expandedVendors, setExpandedVendors] = useState({});
const { data: user } = useQuery({
queryKey: ['current-user-marketplace'],
queryFn: () => base44.auth.me(),
});
const { data: vendors = [] } = useQuery({
queryKey: ['approved-vendors'],
queryFn: async () => {
const allVendors = await base44.entities.Vendor.list();
return allVendors.filter(v => v.approval_status === 'approved' && v.is_active);
},
});
const { data: vendorRates = [] } = useQuery({
queryKey: ['vendor-rates-marketplace'],
queryFn: () => base44.entities.VendorRate.list(),
});
const { data: staff = [] } = useQuery({
queryKey: ['vendor-staff-count'],
queryFn: () => base44.entities.Staff.list(),
});
const { data: events = [] } = useQuery({
queryKey: ['events-vendor-marketplace'],
queryFn: () => base44.entities.Event.list(),
initialData: [],
});
const { data: businesses = [] } = useQuery({
queryKey: ['businesses-vendor-marketplace'],
queryFn: () => base44.entities.Business.list(),
initialData: [],
});
const vendorsWithMetrics = useMemo(() => {
return vendors.map(vendor => {
const rates = vendorRates.filter(r => r.vendor_name === vendor.legal_name || r.vendor_id === vendor.id);
const vendorStaff = staff.filter(s => s.vendor_name === vendor.legal_name);
const avgRate = rates.length > 0
? rates.reduce((sum, r) => sum + (r.client_rate || 0), 0) / rates.length
: 0;
const minRate = rates.length > 0
? Math.min(...rates.map(r => r.client_rate || 999))
: 0;
const rating = 4.5 + (Math.random() * 0.5);
const completedJobs = Math.floor(Math.random() * 200) + 50;
const vendorEvents = events.filter(e =>
e.vendor_name === vendor.legal_name ||
e.vendor_id === vendor.id
);
const uniqueClients = new Set(
vendorEvents.map(e => e.business_name || e.client_email)
).size;
const userSector = user?.sector || user?.company_name;
const sectorClients = businesses.filter(b =>
b.sector === userSector || b.area === user?.area
);
const clientsInSector = new Set(
vendorEvents
.filter(e => sectorClients.some(sc =>
sc.business_name === e.business_name ||
sc.contact_name === e.client_name
))
.map(e => e.business_name || e.client_email)
).size;
const ratesByCategory = rates.reduce((acc, rate) => {
const category = rate.category || 'Other';
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(rate);
return acc;
}, {});
return {
...vendor,
rates,
ratesByCategory,
avgRate,
minRate,
rating,
completedJobs,
staffCount: vendorStaff.length,
responseTime: `${Math.floor(Math.random() * 3) + 1}h`,
totalClients: uniqueClients,
clientsInSector: clientsInSector,
};
});
}, [vendors, vendorRates, staff, events, businesses, user]);
const filteredVendors = useMemo(() => {
let filtered = vendorsWithMetrics;
if (searchTerm) {
const lower = searchTerm.toLowerCase();
filtered = filtered.filter(v =>
v.legal_name?.toLowerCase().includes(lower) ||
v.doing_business_as?.toLowerCase().includes(lower) ||
v.service_specialty?.toLowerCase().includes(lower)
);
}
if (regionFilter !== "all") {
filtered = filtered.filter(v => v.region === regionFilter);
}
if (categoryFilter !== "all") {
filtered = filtered.filter(v => v.service_specialty === categoryFilter);
}
filtered.sort((a, b) => {
switch (sortBy) {
case "rating":
return b.rating - a.rating;
case "price-low":
return a.minRate - b.minRate;
case "price-high":
return b.avgRate - a.avgRate;
case "staff":
return b.staffCount - a.staffCount;
default:
return 0;
}
});
return filtered;
}, [vendorsWithMetrics, searchTerm, regionFilter, categoryFilter, sortBy]);
const preferredVendor = vendorsWithMetrics.find(v => v.id === user?.preferred_vendor_id);
const otherVendors = filteredVendors.filter(v => v.id !== user?.preferred_vendor_id);
const uniqueRegions = [...new Set(vendors.map(v => v.region).filter(Boolean))];
const uniqueCategories = [...new Set(vendors.map(v => v.service_specialty).filter(Boolean))];
const setPreferredMutation = useMutation({
mutationFn: (vendor) => base44.auth.updateMe({
preferred_vendor_id: vendor.id,
preferred_vendor_name: vendor.legal_name || vendor.doing_business_as
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['current-user'] });
queryClient.invalidateQueries({ queryKey: ['current-user-marketplace'] });
toast({
title: "✅ Preferred Vendor Set",
description: "All new orders will route to this vendor by default",
});
},
});
const removePreferredMutation = useMutation({
mutationFn: () => base44.auth.updateMe({
preferred_vendor_id: null,
preferred_vendor_name: null
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['current-user'] });
toast({
title: "✅ Preferred Vendor Removed",
description: "You can now select a new preferred vendor",
});
},
});
const handleContactVendor = (vendor) => {
setContactModal({ open: true, vendor });
setMessage(`Hi ${vendor.legal_name},\n\nI'm interested in your services for an upcoming event. Could you please provide more information about your availability and pricing?\n\nBest regards,\n${user?.full_name || 'Client'}`);
};
const handleSendMessage = async () => {
if (!message.trim()) {
toast({
title: "Message required",
description: "Please enter a message to send.",
variant: "destructive",
});
return;
}
try {
await base44.entities.Conversation.create({
participants: [
{ id: user?.id, name: user?.full_name, role: "client" },
{ id: contactModal.vendor.id, name: contactModal.vendor.legal_name, role: "vendor" }
],
conversation_type: "client-vendor",
is_group: false,
subject: `Inquiry from ${user?.full_name || 'Client'}`,
last_message: message.substring(0, 100),
last_message_at: new Date().toISOString(),
status: "active"
});
toast({
title: "✅ Message sent!",
description: `Your message has been sent to ${contactModal.vendor.legal_name}`,
});
setContactModal({ open: false, vendor: null });
setMessage("");
} catch (error) {
toast({
title: "Failed to send message",
description: error.message,
variant: "destructive",
});
}
};
const handleCreateOrder = (vendor) => {
sessionStorage.setItem('selectedVendor', JSON.stringify({
id: vendor.id,
name: vendor.legal_name,
rates: vendor.rates
}));
navigate(createPageUrl("CreateEvent"));
toast({
title: "Vendor selected",
description: `${vendor.legal_name} will be used for this order.`,
});
};
const toggleVendorRates = (vendorId) => {
setExpandedVendors(prev => ({
...prev,
[vendorId]: !prev[vendorId]
}));
};
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="max-w-[1600px] mx-auto space-y-6">
{/* Hero Header */}
<div className="relative overflow-hidden bg-gradient-to-br from-slate-100 via-purple-50 to-blue-50 rounded-xl p-8 shadow-lg border border-slate-200">
<div className="relative z-10">
<div className="flex items-center gap-3 mb-3">
<div className="w-14 h-14 bg-white shadow-md rounded-xl flex items-center justify-center border border-slate-200">
<Building2 className="w-7 h-7 text-indigo-600" />
</div>
<div>
<h1 className="text-3xl font-bold text-slate-800">Vendor Marketplace</h1>
<p className="text-slate-600 text-sm mt-1">Find the perfect vendor partner for your staffing needs</p>
</div>
</div>
<div className="flex items-center gap-4 mt-5">
<div className="flex items-center gap-2 px-4 py-2 bg-white shadow-sm rounded-lg border border-slate-200">
<Users className="w-4 h-4 text-indigo-600" />
<span className="text-slate-700 font-semibold">{filteredVendors.length} Active Vendors</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 bg-white shadow-sm rounded-lg border border-slate-200">
<Star className="w-4 h-4 text-amber-500 fill-amber-400" />
<span className="text-slate-700 font-semibold">Verified & Approved</span>
</div>
</div>
</div>
</div>
{/* Preferred Vendor Section */}
{preferredVendor ? (
<Card className="border-2 border-[#0A39DF] bg-white shadow-lg overflow-hidden">
<CardHeader className="bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-slate-200 pb-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-5">
<div className="relative">
<div className="w-16 h-16 bg-gradient-to-br from-blue-400 to-blue-500 rounded-xl flex items-center justify-center shadow-md">
<Handshake className="w-8 h-8 text-white" />
</div>
<div className="absolute -top-1 -right-1 w-6 h-6 bg-blue-500 rounded-full border-2 border-white flex items-center justify-center shadow-md">
<DollarSign className="w-3 h-3 text-white" />
</div>
</div>
<div>
<Badge className="bg-[#0A39DF] text-white font-bold px-3 py-1 mb-2">
PREFERRED VENDOR
</Badge>
<h2 className="text-2xl font-bold text-slate-900">
{preferredVendor.doing_business_as || preferredVendor.legal_name}
</h2>
<p className="text-sm text-slate-600 mt-1">Your default vendor for all new orders</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removePreferredMutation.mutate()}
disabled={removePreferredMutation.isPending}
className="text-slate-600 hover:text-red-600 hover:bg-red-50"
>
<X className="w-4 h-4 mr-1" />
Remove
</Button>
</div>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-6 gap-3 mb-5">
{/* Stats Grid */}
<div className="text-center p-4 bg-slate-50/50 rounded-lg border border-slate-200">
<Users className="w-5 h-5 mx-auto mb-2 text-slate-600" />
<p className="text-2xl font-bold text-slate-900">{preferredVendor.staffCount}</p>
<p className="text-xs text-slate-600 mt-1 font-medium">Staff</p>
</div>
<div className="text-center p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<Star className="w-5 h-5 mx-auto mb-2 text-amber-600 fill-amber-500" />
<p className="text-2xl font-bold text-slate-900">{preferredVendor.rating.toFixed(1)}</p>
<p className="text-xs text-slate-600 mt-1 font-medium">Rating</p>
</div>
<div className="text-center p-4 bg-teal-50 rounded-lg border border-teal-200">
<Target className="w-5 h-5 mx-auto mb-2 text-teal-600" />
<p className="text-2xl font-bold text-slate-900">98%</p>
<p className="text-xs text-slate-600 mt-1 font-medium">Fill Rate</p>
</div>
<div className="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
<Clock className="w-5 h-5 mx-auto mb-2 text-blue-600" />
<p className="text-2xl font-bold text-slate-900">{preferredVendor.responseTime}</p>
<p className="text-xs text-slate-600 mt-1 font-medium">Response</p>
</div>
<div className="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
<DollarSign className="w-5 h-5 mx-auto mb-2 text-blue-600" />
<p className="text-2xl font-bold text-slate-900">${Math.round(preferredVendor.minRate)}</p>
<p className="text-xs text-slate-600 mt-1 font-medium">From/hr</p>
</div>
<div className="text-center p-4 bg-green-50 rounded-lg border border-green-200">
<CheckCircle className="w-5 h-5 mx-auto mb-2 text-green-600" />
<p className="text-2xl font-bold text-slate-900">{preferredVendor.completedJobs}</p>
<p className="text-xs text-slate-600 mt-1 font-medium">Jobs</p>
</div>
</div>
{/* Benefits Banner */}
<div className="grid grid-cols-3 gap-3">
<div className="bg-green-50 border border-green-200 rounded-lg p-3 flex items-center gap-3">
<div className="w-9 h-9 bg-white border border-green-200 shadow-sm rounded-lg flex items-center justify-center flex-shrink-0">
<Zap className="w-4 h-4 text-green-600" />
</div>
<div>
<p className="font-bold text-slate-800 text-sm">Priority Support</p>
<p className="text-xs text-slate-600">Faster responses</p>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 flex items-center gap-3">
<div className="w-9 h-9 bg-white border border-blue-200 shadow-sm rounded-lg flex items-center justify-center flex-shrink-0">
<Shield className="w-4 h-4 text-blue-600" />
</div>
<div>
<p className="font-bold text-slate-800 text-sm">Dedicated Manager</p>
<p className="text-xs text-slate-600">Direct contact</p>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 flex items-center gap-3">
<div className="w-9 h-9 bg-white border border-blue-200 shadow-sm rounded-lg flex items-center justify-center flex-shrink-0">
<TrendingUp className="w-4 h-4 text-blue-600" />
</div>
<div>
<p className="font-bold text-slate-800 text-sm">Better Rates</p>
<p className="text-xs text-slate-600">Volume pricing</p>
</div>
</div>
</div>
</CardContent>
</Card>
) : (
<Card className="border-2 border-dashed border-slate-300 bg-slate-50">
<CardContent className="p-8 text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-slate-200 rounded-xl flex items-center justify-center">
<Crown className="w-8 h-8 text-slate-400" />
</div>
<h3 className="font-bold text-xl text-slate-900 mb-2">
Set Your Preferred Vendor
</h3>
<p className="text-slate-600 mb-6 max-w-2xl mx-auto">
Choose a default vendor for faster ordering and streamlined operations. You'll get priority support and better rates.
</p>
<div className="flex items-center justify-center gap-3">
<div className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg border border-slate-200">
<Zap className="w-4 h-4 text-[#0A39DF]" />
<span className="text-sm font-medium text-slate-700">Quick Orders</span>
</div>
<div className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg border border-slate-200">
<Shield className="w-4 h-4 text-green-600" />
<span className="text-sm font-medium text-slate-700">Priority Support</span>
</div>
<div className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg border border-slate-200">
<Target className="w-4 h-4 text-indigo-600" />
<span className="text-sm font-medium text-slate-700">Better Rates</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Stats Cards */}
<div className="grid 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">Vendors</p>
<p className="text-3xl font-bold text-slate-900 mb-0.5">{vendors.length}</p>
<p className="text-slate-500 text-xs">Approved</p>
</div>
<div className="w-12 h-12 bg-white border border-slate-200 shadow-sm rounded-xl flex items-center justify-center">
<Building2 className="w-6 h-6 text-slate-600" />
</div>
</div>
</CardContent>
</Card>
<Card className="border border-yellow-200 bg-yellow-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">Rating</p>
<div className="flex items-center gap-2 mb-0.5">
<p className="text-3xl font-bold text-slate-900">4.7</p>
<Star className="w-5 h-5 text-amber-500 fill-amber-500" />
</div>
<p className="text-slate-500 text-xs">Average</p>
</div>
<div className="w-12 h-12 bg-white border border-yellow-200 shadow-sm rounded-xl flex items-center justify-center">
<Award className="w-6 h-6 text-amber-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">Fill Rate</p>
<p className="text-3xl font-bold text-slate-900 mb-0.5">98%</p>
<p className="text-slate-500 text-xs">Success rate</p>
</div>
<div className="w-12 h-12 bg-white border border-teal-200 shadow-sm rounded-xl flex items-center justify-center">
<Target 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">Response</p>
<p className="text-3xl font-bold text-slate-900 mb-0.5">2h</p>
<p className="text-slate-500 text-xs">Avg time</p>
</div>
<div className="w-12 h-12 bg-white border border-blue-200 shadow-sm rounded-xl flex items-center justify-center">
<Clock className="w-6 h-6 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card className="border border-slate-200 shadow-sm bg-white">
<CardContent className="p-5">
<div className="grid grid-cols-12 gap-4 items-end">
<div className="col-span-5">
<label className="text-xs font-semibold text-slate-700 mb-2 block">
Search Vendors
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search by name, specialty, or location..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-10 border border-slate-300 focus:border-[#0A39DF] text-sm"
/>
</div>
</div>
<div className="col-span-2">
<label className="text-xs font-semibold text-slate-700 mb-2 block">
Region
</label>
<Select value={regionFilter} onValueChange={setRegionFilter}>
<SelectTrigger className="h-10 border border-slate-300">
<SelectValue placeholder="All Regions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Regions</SelectItem>
{uniqueRegions.map(region => (
<SelectItem key={region} value={region}>{region}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="col-span-2">
<label className="text-xs font-semibold text-slate-700 mb-2 block">
Specialty
</label>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="h-10 border border-slate-300">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Specialties</SelectItem>
{uniqueCategories.map(cat => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="col-span-2">
<label className="text-xs font-semibold text-slate-700 mb-2 block">
Sort By
</label>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="h-10 border border-slate-300">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="rating">⭐ Highest Rated</SelectItem>
<SelectItem value="price-low">💰 Lowest Price</SelectItem>
<SelectItem value="price-high">💎 Premium</SelectItem>
<SelectItem value="staff">👥 Most Staff</SelectItem>
</SelectContent>
</Select>
</div>
<div className="col-span-1 flex items-end">
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-full">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className={`flex-1 ${viewMode === "grid" ? "bg-[#0A39DF]" : ""}`}
>
<Grid className="w-4 h-4" />
</Button>
<Button
variant={viewMode === "list" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("list")}
className={`flex-1 ${viewMode === "list" ? "bg-[#0A39DF]" : ""}`}
>
<List className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Other Vendors Section Title */}
{preferredVendor && (
<div className="flex items-center gap-3 my-2">
<div className="h-px flex-1 bg-slate-200" />
<h2 className="text-base font-bold text-slate-700">
Other Available Vendors
</h2>
<div className="h-px flex-1 bg-slate-200" />
</div>
)}
{/* Vendors Grid */}
{viewMode === "grid" ? (
<div className="space-y-6">
{otherVendors.map((vendor) => {
const isExpanded = expandedVendors[vendor.id];
return (
<Card key={vendor.id} className="bg-white border border-slate-200 hover:border-blue-300 hover:shadow-lg transition-all group">
<CardHeader className="bg-gradient-to-br from-slate-50 to-blue-50/30 border-b border-slate-200 pb-4">
<div className="flex items-start justify-between gap-6">
<div className="flex items-center gap-4 flex-1">
<div className="relative">
<Avatar className="w-16 h-16 bg-blue-100 shadow-lg ring-2 ring-blue-200">
{vendor.company_logo ? (
<AvatarImage src={vendor.company_logo} alt={vendor.legal_name} />
) : null}
<AvatarFallback className="text-blue-700 text-xl font-bold bg-blue-100">
{vendor.legal_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="absolute -bottom-1 -right-1 w-6 h-6 bg-green-500 rounded-full border-2 border-white flex items-center justify-center shadow-md">
<CheckCircle className="w-3.5 h-3.5 text-white" />
</div>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<CardTitle className="text-xl font-bold text-slate-800 group-hover:text-blue-700 transition-colors">
{vendor.legal_name}
</CardTitle>
<div className="flex items-center gap-1.5 bg-yellow-50 px-3 py-1.5 rounded-full border border-yellow-200">
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
<span className="text-sm font-bold text-slate-800">{vendor.rating.toFixed(1)}</span>
</div>
</div>
{vendor.doing_business_as && (
<p className="text-xs text-slate-500 mb-3 italic">DBA: {vendor.doing_business_as}</p>
)}
<div className="flex items-center gap-4 flex-wrap">
{vendor.service_specialty && (
<Badge className="bg-blue-100 text-blue-700 border border-blue-200">
{vendor.service_specialty}
</Badge>
)}
<span className="flex items-center gap-1.5 text-sm text-slate-700">
<MapPin className="w-4 h-4 text-slate-500" />
{vendor.region || vendor.city}
</span>
<span className="flex items-center gap-1.5 text-sm text-slate-700">
<Users className="w-4 h-4 text-slate-500" />
{vendor.staffCount} Staff
</span>
<span className="flex items-center gap-1.5 text-sm text-slate-700">
<Clock className="w-4 h-4 text-teal-600" />
{vendor.responseTime}
</span>
</div>
</div>
</div>
<div className="flex flex-col items-end gap-3">
<div className="p-4 bg-blue-50 border border-blue-200 rounded-xl shadow-sm text-center min-w-[140px]">
<p className="text-slate-600 text-[10px] mb-1 font-semibold uppercase tracking-wide">Starting from</p>
<p className="text-3xl font-bold text-slate-900 mb-1">${vendor.minRate}</p>
<p className="text-slate-600 text-xs">per hour</p>
</div>
{vendor.clientsInSector > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 shadow-sm min-w-[140px]">
<div className="flex items-center justify-center gap-2 mb-1">
<UserCheck className="w-5 h-5 text-blue-600" />
<span className="text-2xl font-bold text-slate-900">{vendor.clientsInSector}</span>
</div>
<p className="text-[10px] text-slate-600 font-bold text-center uppercase tracking-wide">
in your area
</p>
</div>
)}
<div className="flex items-center gap-2">
<Badge className="bg-green-50 text-green-700 border-green-200 border px-3 py-1.5 text-xs">
<CheckCircle className="w-3 h-3 mr-1" />
{vendor.completedJobs} jobs
</Badge>
<Badge variant="outline" className="border-slate-300 bg-slate-50/50 px-3 py-1.5 text-xs font-semibold">
{vendor.rates.length} services
</Badge>
</div>
</div>
</div>
</CardHeader>
<div className="px-5 py-4 bg-slate-50/50 border-b border-slate-100">
<div className="flex items-center justify-between">
<Collapsible open={isExpanded} onOpenChange={() => toggleVendorRates(vendor.id)} className="flex-1">
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-auto px-4 py-2 hover:bg-blue-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-9 h-9 bg-white border border-slate-200 shadow-sm rounded-lg flex items-center justify-center">
<TrendingUp className="w-4 h-4 text-blue-600" />
</div>
<div className="text-left">
<span className="font-bold text-slate-800 text-base">Compare Rates</span>
<span className="text-xs text-slate-500 block">{vendor.rates.length} services</span>
</div>
{isExpanded ? <ChevronUp className="w-4 h-4 text-slate-400 ml-2" /> : <ChevronDown className="w-4 h-4 text-slate-400 ml-2" />}
</div>
</Button>
</CollapsibleTrigger>
</Collapsible>
<div className="flex items-center gap-2">
<Button
onClick={() => setPreferredMutation.mutate(vendor)}
disabled={setPreferredMutation.isPending}
className="bg-cyan-100 hover:bg-cyan-200 text-slate-800 font-bold shadow-sm border border-cyan-200"
>
<Award className="w-4 h-4 mr-2" />
Set as Preferred
</Button>
<Button
onClick={() => handleContactVendor(vendor)}
className="bg-amber-50 hover:bg-amber-100 text-slate-800 border border-amber-200"
>
<MessageSquare className="w-4 h-4 mr-2" />
Contact
</Button>
<Button
onClick={() => handleCreateOrder(vendor)}
className="bg-purple-100 hover:bg-purple-200 text-slate-800 shadow-sm border border-purple-200"
>
<Zap className="w-4 h-4 mr-2" />
Order Now
</Button>
</div>
</div>
</div>
<Collapsible open={isExpanded}>
<CollapsibleContent>
<CardContent className="p-6 bg-slate-50/50">
<div className="space-y-4">
{Object.entries(vendor.ratesByCategory).map(([category, categoryRates]) => (
<div key={category} className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
<div className="bg-gradient-to-r from-slate-100 to-purple-50 px-5 py-3 border-b border-slate-200">
<h4 className="font-bold text-slate-800 text-sm flex items-center gap-2">
<Briefcase className="w-4 h-4 text-slate-600" />
{category}
<Badge className="bg-slate-200 text-slate-700 border-0 ml-auto">
{categoryRates.length}
</Badge>
</h4>
</div>
<div className="divide-y divide-slate-100">
{categoryRates.map((rate, idx) => {
return (
<div key={rate.id} className="p-4 hover:bg-blue-50/30 transition-all">
<div className="flex items-center justify-between gap-6">
<div className="flex items-center gap-3 flex-1">
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center font-bold text-blue-700 text-sm">
{idx + 1}
</div>
<h5 className="font-bold text-slate-900 text-base">{rate.role_name}</h5>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-xl px-6 py-3 shadow-sm">
<p className="text-3xl font-bold text-slate-900">${rate.client_rate?.toFixed(0)}</p>
<p className="text-slate-600 text-xs text-center mt-1">per hour</p>
</div>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
})}
</div>
) : (
<Card className="border-2 border-slate-200 shadow-xl">
<CardContent className="p-0">
<table className="w-full">
<thead className="bg-gradient-to-r from-slate-50 to-blue-50 border-b-2 border-slate-200">
<tr>
<th className="text-left py-5 px-5 text-xs font-bold text-slate-700 uppercase">Vendor</th>
<th className="text-left py-5 px-5 text-xs font-bold text-slate-700 uppercase">Specialty</th>
<th className="text-left py-5 px-5 text-xs font-bold text-slate-700 uppercase">Location</th>
<th className="text-center py-5 px-5 text-xs font-bold text-slate-700 uppercase">Rating</th>
<th className="text-center py-5 px-5 text-xs font-bold text-slate-700 uppercase">Clients</th>
<th className="text-center py-5 px-5 text-xs font-bold text-slate-700 uppercase">Staff</th>
<th className="text-center py-5 px-5 text-xs font-bold text-slate-700 uppercase">Min Rate</th>
<th className="text-center py-5 px-5 text-xs font-bold text-slate-700 uppercase">Actions</th>
</tr>
</thead>
<tbody>
{otherVendors.map((vendor) => (
<tr key={vendor.id} className="border-b border-slate-100 hover:bg-blue-50/30 transition-all">
<td className="py-5 px-5">
<div className="flex items-center gap-3">
<Avatar className="w-12 h-12 bg-blue-100 shadow-md">
{vendor.company_logo ? (
<AvatarImage src={vendor.company_logo} alt={vendor.legal_name} />
) : null}
<AvatarFallback className="text-blue-700 font-bold text-lg bg-blue-100">
{vendor.legal_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-bold text-slate-800">{vendor.legal_name}</p>
<p className="text-xs text-slate-500">{vendor.completedJobs} jobs completed</p>
</div>
</div>
</td>
<td className="py-5 px-5 text-sm text-slate-700">{vendor.service_specialty || ''}</td>
<td className="py-5 px-5">
<span className="flex items-center gap-1.5 text-sm text-slate-700">
<MapPin className="w-4 h-4 text-slate-500" />
{vendor.region}
</span>
</td>
<td className="py-5 px-5 text-center">
<div className="inline-flex items-center gap-2 bg-yellow-50 px-3 py-1.5 rounded-full border border-yellow-200">
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
<span className="font-bold text-slate-800">{vendor.rating.toFixed(1)}</span>
</div>
</td>
<td className="py-5 px-5 text-center">
{vendor.clientsInSector > 0 ? (
<Badge className="bg-blue-100 text-blue-700 border border-blue-200">
<UserCheck className="w-3 h-3 mr-1" />
{vendor.clientsInSector}
</Badge>
) : (
<span className="text-slate-400"></span>
)}
</td>
<td className="py-5 px-5 text-center">
<Badge variant="outline" className="font-bold border-slate-300">{vendor.staffCount}</Badge>
</td>
<td className="py-5 px-5 text-center">
<div className="inline-flex flex-col bg-blue-50 border border-blue-200 px-4 py-2 rounded-xl">
<span className="font-bold text-xl text-slate-900">${vendor.minRate}</span>
<span className="text-xs text-slate-600">/hour</span>
</div>
</td>
<td className="py-5 px-5">
<div className="flex items-center justify-center gap-2">
<Button
size="sm"
onClick={() => setPreferredMutation.mutate(vendor)}
disabled={setPreferredMutation.isPending}
className="bg-cyan-100 hover:bg-cyan-200 text-slate-800 border border-cyan-200"
>
<Award className="w-3 h-3 mr-1" />
Set Preferred
</Button>
<Button
size="sm"
onClick={() => handleContactVendor(vendor)}
className="bg-amber-50 hover:bg-amber-100 text-slate-800 border border-amber-200"
>
<MessageSquare className="w-3 h-3 mr-1" />
Contact
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
{otherVendors.length === 0 && !preferredVendor && (
<div className="text-center py-16 bg-white rounded-2xl border-2 border-dashed border-slate-300">
<Building2 className="w-16 h-16 mx-auto mb-4 text-slate-300" />
<h3 className="text-xl font-bold text-slate-900 mb-2">No vendors found</h3>
<p className="text-slate-600 mb-5">Try adjusting your filters</p>
<Button
variant="outline"
onClick={() => {
setSearchTerm("");
setRegionFilter("all");
setCategoryFilter("all");
}}
>
Clear Filters
</Button>
</div>
)}
</div>
{/* Contact Modal */}
<Dialog open={contactModal.open} onOpenChange={(open) => setContactModal({ open, vendor: null })}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold text-[#1C323E]">
Contact {contactModal.vendor?.legal_name}
</DialogTitle>
<DialogDescription>
Start a conversation and get staffing help within hours
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-4">
<div className="flex items-center gap-4 p-5 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border-2 border-blue-200">
<Avatar className="w-16 h-16 bg-blue-100 ring-2 ring-white shadow-md">
{contactModal.vendor?.company_logo ? (
<AvatarImage src={contactModal.vendor?.company_logo} alt={contactModal.vendor?.legal_name} />
) : null}
<AvatarFallback className="text-blue-700 text-xl font-bold bg-blue-100">
{contactModal.vendor?.legal_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<h4 className="font-bold text-[#1C323E] text-lg mb-2">{contactModal.vendor?.legal_name}</h4>
<div className="flex items-center gap-2 flex-wrap">
<Badge className="bg-white text-slate-700">
<MapPin className="w-3 h-3 mr-1" />
{contactModal.vendor?.region}
</Badge>
<Badge className="bg-white text-slate-700">
<Users className="w-3 h-3 mr-1" />
{contactModal.vendor?.staffCount} staff
</Badge>
<Badge className="bg-amber-50 text-amber-700">
<Star className="w-3 h-3 mr-1 fill-amber-500" />
{contactModal.vendor?.rating?.toFixed(1)}
</Badge>
</div>
</div>
</div>
<div>
<label className="text-sm font-bold text-slate-700 mb-2 block">
<MessageSquare className="w-4 h-4 inline mr-2 text-[#0A39DF]" />
Your Message
</label>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={8}
placeholder="Enter your message..."
className="border-2 border-slate-200 focus:border-[#0A39DF]"
/>
<p className="text-xs text-slate-500 mt-2 bg-blue-50 p-2 rounded">
💡 <strong>Tip:</strong> Include event date, location, and staff needed
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setContactModal({ open: false, vendor: null })}>
Cancel
</Button>
<Button onClick={handleSendMessage} className="bg-[#0A39DF] hover:bg-blue-700">
<MessageSquare className="w-4 h-4 mr-2" />
Send Message
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,752 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
ArrowLeft,
ArrowRight,
Check,
FileText,
Building2,
DollarSign,
Shield,
MapPin,
Sparkles,
FileCheck,
TrendingUp
} from "lucide-react";
import PageHeader from "../components/common/PageHeader";
import { useToast } from "@/components/ui/use-toast";
const ONBOARDING_STEPS = [
{ id: 1, title: "Vendor Onboard", icon: Building2, description: "Basic vendor information" },
{ id: 2, title: "NDA", icon: Shield, description: "Sign and Accept Non-Disclosure Agreement" },
{ id: 3, title: "Contract", icon: FileText, description: "Master service agreement" },
{ id: 4, title: "Performance & VA Structure", icon: TrendingUp, description: "Performance metrics and VA structure" },
{ id: 5, title: "Documents & Validation", icon: FileCheck, description: "Upload compliance documents" },
{ id: 6, title: "Service & Coverage", icon: MapPin, description: "Service areas and coverage" },
{ id: 7, title: "Rate Proposal", icon: DollarSign, description: "Submit your rate proposal" },
{ id: 8, title: "AI Intelligence", icon: Sparkles, description: "AI-powered optimization" }
];
export default function VendorOnboarding() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const [currentStep, setCurrentStep] = useState(1);
const { data: user } = useQuery({
queryKey: ['current-user-onboarding'],
queryFn: () => base44.auth.me(),
});
const userRole = user?.user_role || user?.role;
const isBuyer = userRole === "procurement" || userRole === "admin";
const [vendorData, setVendorData] = useState({
// Step 1: Vendor Onboard
legal_name: "",
doing_business_as: "",
tax_id: "",
business_type: "LLC",
primary_contact_name: "",
primary_contact_email: "",
primary_contact_phone: "",
billing_address: "",
// Step 2: NDA
nda_acknowledged: false,
nda_signed_by: "",
nda_signature_date: "",
nda_signature_time: "",
// Step 3: Contract
contract_acknowledged: false,
contract_signed_date: "",
// Step 4: Performance & VA Structure
performance_metrics_agreed: false,
va_structure_type: "",
// Step 5: Documents & Validation
w9_document: "",
coi_document: "",
insurance_expiry: "",
// Step 6: Service & Coverage
coverage_regions: [],
eligible_roles: [],
// Step 7: Rate Proposal
rate_proposal_submitted: false,
// Step 8: AI Intelligence
ai_optimization_enabled: false,
// Status
approval_status: "pending",
});
const generateVendorNumber = () => {
const randomNum = Math.floor(1000 + Math.random() * 9000);
return `VN-${randomNum}`;
};
const createVendorMutation = useMutation({
mutationFn: (data) => base44.entities.Vendor.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vendors'] });
toast({
title: "✅ Vendor Onboarding Complete",
description: "Vendor submitted for approval",
});
navigate(createPageUrl("VendorManagement"));
},
});
const handleNext = () => {
// Validation for Step 2 (NDA)
if (currentStep === 2 && !vendorData.nda_acknowledged) {
toast({
title: "⚠️ NDA Required",
description: "You must sign and accept the NDA before proceeding",
variant: "destructive",
});
return;
}
if (currentStep < 8) {
setCurrentStep(currentStep + 1);
}
};
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
const handleSubmit = () => {
const vendorNumber = generateVendorNumber();
createVendorMutation.mutate({
...vendorData,
vendor_number: vendorNumber,
is_active: false,
});
};
const handleFileUpload = async (file, field) => {
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
setVendorData({ ...vendorData, [field]: file_url });
toast({
title: "✅ File Uploaded",
description: "Document uploaded successfully",
});
} catch (error) {
toast({
title: "❌ Upload Failed",
description: "Failed to upload document",
variant: "destructive",
});
}
};
const handleSignNDA = () => {
const now = new Date();
setVendorData({
...vendorData,
nda_acknowledged: true,
nda_signed_by: user?.full_name || user?.email,
nda_signature_date: now.toLocaleDateString(),
nda_signature_time: now.toLocaleTimeString()
});
toast({
title: "✅ NDA Signed",
description: `Signed by ${user?.full_name || user?.email} on ${now.toLocaleString()}`,
});
};
if (!isBuyer) {
return (
<div className="p-8">
<Card className="max-w-2xl mx-auto border-red-200 bg-red-50">
<CardContent className="p-8 text-center">
<Shield className="w-16 h-16 mx-auto text-red-600 mb-4" />
<h2 className="text-xl font-bold text-red-900 mb-2">Access Denied</h2>
<p className="text-red-700">Only Procurement and Admin users can onboard vendors.</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-6xl mx-auto">
<PageHeader
title="🚀 Vendor Onboarding Process"
subtitle="Complete all 8 steps to join our vendor network"
backTo={createPageUrl("VendorManagement")}
backButtonLabel="Back to Vendors"
/>
{/* Progress Stepper */}
<Card className="mb-8 border-slate-200 shadow-xl">
<CardContent className="p-6">
<div className="grid grid-cols-8 gap-2">
{ONBOARDING_STEPS.map((step, index) => {
const isActive = currentStep === step.id;
const isCompleted = currentStep > step.id;
const Icon = step.icon;
return (
<div key={step.id} className="flex flex-col items-center">
<div
className={`w-16 h-16 rounded-full flex items-center justify-center mb-2 transition-all ${
isCompleted
? "bg-green-500 text-white shadow-lg"
: isActive
? "bg-[#0A39DF] text-white shadow-lg scale-110"
: "bg-slate-200 text-slate-500"
}`}
>
{isCompleted ? <Check className="w-8 h-8" /> : <Icon className="w-8 h-8" />}
</div>
<span
className={`text-xs font-medium text-center ${
isActive ? "text-[#0A39DF]" : isCompleted ? "text-green-600" : "text-slate-500"
}`}
>
Step {step.id}
</span>
<span
className={`text-xs text-center font-bold ${
isActive ? "text-[#0A39DF]" : isCompleted ? "text-green-600" : "text-slate-400"
}`}
>
{step.title}
</span>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Step Content */}
<Card className="mb-8 border-slate-200 shadow-xl">
<CardHeader className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] text-white">
<div className="flex items-center gap-4">
{React.createElement(ONBOARDING_STEPS[currentStep - 1].icon, { className: "w-8 h-8" })}
<div>
<CardTitle className="text-2xl">
Step {currentStep}: {ONBOARDING_STEPS[currentStep - 1].title}
</CardTitle>
<p className="text-blue-100 text-sm mt-1">
{ONBOARDING_STEPS[currentStep - 1].description}
</p>
</div>
</div>
</CardHeader>
<CardContent className="p-8">
{/* STEP 1: Vendor Onboard */}
{currentStep === 1 && (
<div className="space-y-6">
<h3 className="text-xl font-bold text-[#1C323E] mb-4">📝 Basic Vendor Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label className="text-slate-700 font-semibold">Legal Business Name *</Label>
<Input
value={vendorData.legal_name}
onChange={(e) => setVendorData({ ...vendorData, legal_name: e.target.value })}
placeholder="ABC Staffing LLC"
className="mt-2"
/>
</div>
<div>
<Label className="text-slate-700 font-semibold">Doing Business As (DBA)</Label>
<Input
value={vendorData.doing_business_as}
onChange={(e) => setVendorData({ ...vendorData, doing_business_as: e.target.value })}
placeholder="ABC Staff"
className="mt-2"
/>
</div>
<div>
<Label className="text-slate-700 font-semibold">Federal Tax ID / EIN *</Label>
<Input
value={vendorData.tax_id}
onChange={(e) => setVendorData({ ...vendorData, tax_id: e.target.value })}
placeholder="12-3456789"
className="mt-2"
/>
</div>
<div>
<Label className="text-slate-700 font-semibold">Business Type *</Label>
<Select
value={vendorData.business_type}
onValueChange={(value) => setVendorData({ ...vendorData, business_type: value })}
>
<SelectTrigger className="mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Corporation">Corporation</SelectItem>
<SelectItem value="LLC">LLC</SelectItem>
<SelectItem value="Partnership">Partnership</SelectItem>
<SelectItem value="Sole Proprietorship">Sole Proprietorship</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-slate-700 font-semibold">Primary Contact Name *</Label>
<Input
value={vendorData.primary_contact_name}
onChange={(e) => setVendorData({ ...vendorData, primary_contact_name: e.target.value })}
placeholder="John Doe"
className="mt-2"
/>
</div>
<div>
<Label className="text-slate-700 font-semibold">Primary Contact Email *</Label>
<Input
type="email"
value={vendorData.primary_contact_email}
onChange={(e) => setVendorData({ ...vendorData, primary_contact_email: e.target.value })}
placeholder="john@abcstaff.com"
className="mt-2"
/>
</div>
<div>
<Label className="text-slate-700 font-semibold">Primary Contact Phone *</Label>
<Input
value={vendorData.primary_contact_phone}
onChange={(e) => setVendorData({ ...vendorData, primary_contact_phone: e.target.value })}
placeholder="(555) 123-4567"
className="mt-2"
/>
</div>
</div>
<div>
<Label className="text-slate-700 font-semibold">Billing Address *</Label>
<Textarea
value={vendorData.billing_address}
onChange={(e) => setVendorData({ ...vendorData, billing_address: e.target.value })}
placeholder="123 Main St, San Francisco, CA 94102"
rows={3}
className="mt-2"
/>
</div>
</div>
)}
{/* STEP 2: NDA - Sign and Accept */}
{currentStep === 2 && (
<div className="space-y-6">
<div className="p-6 bg-purple-50 border-2 border-purple-300 rounded-xl">
<div className="flex items-start gap-4">
<Shield className="w-8 h-8 text-purple-600 flex-shrink-0 mt-1" />
<div>
<h3 className="text-xl font-bold text-purple-900 mb-2">
🔒 Non-Disclosure Agreement (NDA)
</h3>
<p className="text-purple-700">
Review and sign the NDA between your company and Foodbuy, LLC. This is a legally binding document.
</p>
</div>
</div>
</div>
{/* NDA Document Viewer */}
<div className="border-2 border-slate-300 rounded-xl overflow-hidden shadow-lg">
<div className="bg-gradient-to-r from-purple-600 to-purple-800 px-6 py-4 flex items-center justify-between">
<h4 className="font-bold text-white text-lg">📄 NDA Document</h4>
<a
href="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/1d8cf6dbf_LegendaryEventStaffingFOODBUYVendorNDA.pdf"
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-white text-purple-700 rounded-lg hover:bg-purple-50 font-semibold text-sm flex items-center gap-2"
>
<FileText className="w-4 h-4" />
Open in New Tab
</a>
</div>
<div className="h-[600px] bg-white">
<iframe
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/1d8cf6dbf_LegendaryEventStaffingFOODBUYVendorNDA.pdf"
className="w-full h-full"
title="NDA Document"
/>
</div>
</div>
{/* NDA Key Terms */}
<div className="p-6 bg-slate-50 border border-slate-200 rounded-xl">
<h4 className="font-bold text-slate-900 mb-4 text-lg">📋 Key Terms Summary:</h4>
<ul className="space-y-3">
<li className="flex items-start gap-3">
<span className="w-2 h-2 bg-purple-600 rounded-full mt-2"></span>
<span className="text-slate-700">
<strong>Confidentiality:</strong> All proprietary information, pricing, and client data must remain confidential
</span>
</li>
<li className="flex items-start gap-3">
<span className="w-2 h-2 bg-purple-600 rounded-full mt-2"></span>
<span className="text-slate-700">
<strong>Duration:</strong> Obligations continue for 2 years after relationship termination
</span>
</li>
<li className="flex items-start gap-3">
<span className="w-2 h-2 bg-purple-600 rounded-full mt-2"></span>
<span className="text-slate-700">
<strong>Governing Law:</strong> North Carolina law applies
</span>
</li>
<li className="flex items-start gap-3">
<span className="w-2 h-2 bg-purple-600 rounded-full mt-2"></span>
<span className="text-slate-700">
<strong>Use Restrictions:</strong> Information only for business relationship purposes
</span>
</li>
</ul>
</div>
{/* Sign and Accept Section */}
<div className={`p-8 border-4 rounded-xl transition-all ${
vendorData.nda_acknowledged
? 'bg-green-50 border-green-400'
: 'bg-white border-purple-300'
}`}>
{!vendorData.nda_acknowledged ? (
<div>
<h4 className="text-xl font-bold text-slate-900 mb-4"> Sign and Accept NDA</h4>
<p className="text-slate-700 mb-6 leading-relaxed">
By clicking the button below, I acknowledge that I have read, understood, and agree to be bound by
the terms of the Confidentiality/Non-Disclosure Agreement between my company and Foodbuy, LLC.
</p>
<Button
onClick={handleSignNDA}
className="w-full bg-gradient-to-r from-purple-600 to-purple-800 hover:from-purple-700 hover:to-purple-900 text-white text-lg py-6 font-bold shadow-xl"
>
<Check className="w-6 h-6 mr-3" />
I SIGN AND ACCEPT THE NDA
</Button>
</div>
) : (
<div className="text-center">
<div className="w-20 h-20 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-4">
<Check className="w-12 h-12 text-white" />
</div>
<h4 className="text-2xl font-bold text-green-900 mb-4"> NDA Signed Successfully</h4>
<div className="bg-white p-6 rounded-lg border-2 border-green-300 max-w-md mx-auto">
<div className="space-y-2 text-left">
<div className="flex justify-between">
<span className="font-semibold text-slate-700">Signed by:</span>
<span className="text-slate-900">{vendorData.nda_signed_by}</span>
</div>
<div className="flex justify-between">
<span className="font-semibold text-slate-700">Date:</span>
<span className="text-slate-900">{vendorData.nda_signature_date}</span>
</div>
<div className="flex justify-between">
<span className="font-semibold text-slate-700">Time:</span>
<span className="text-slate-900">{vendorData.nda_signature_time}</span>
</div>
</div>
</div>
<p className="text-green-700 mt-4 font-semibold">
You may now proceed to the next step
</p>
</div>
)}
</div>
</div>
)}
{/* STEP 3: Contract */}
{currentStep === 3 && (
<div className="space-y-6">
<h3 className="text-xl font-bold text-[#1C323E]">📋 Master Service Agreement</h3>
<div className="p-6 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-blue-900">
Review and sign the Master Service Agreement. This document outlines the terms of our business relationship.
</p>
</div>
<div className="h-96 bg-slate-100 rounded-lg flex items-center justify-center border-2 border-dashed border-slate-300">
<p className="text-slate-500">Contract document viewer will appear here</p>
</div>
</div>
)}
{/* STEP 4: Performance & VA Structure */}
{currentStep === 4 && (
<div className="space-y-6">
<h3 className="text-xl font-bold text-[#1C323E]">📊 Performance Metrics & VA Structure</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="p-6 bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl border border-blue-200">
<h4 className="font-bold text-blue-900 mb-3">Performance Metrics</h4>
<ul className="space-y-2 text-sm text-blue-800">
<li> Fill Rate Target: 95%+</li>
<li> On-Time Arrival: 98%+</li>
<li> Quality Rating: 4.5/5+</li>
<li> Cancellation Rate: &lt;5%</li>
</ul>
</div>
<div className="p-6 bg-gradient-to-br from-purple-50 to-purple-100 rounded-xl border border-purple-200">
<h4 className="font-bold text-purple-900 mb-3">VA Structure</h4>
<Label className="text-purple-800 font-semibold">Select Structure Type</Label>
<Select
value={vendorData.va_structure_type}
onValueChange={(value) => setVendorData({ ...vendorData, va_structure_type: value })}
>
<SelectTrigger className="mt-2">
<SelectValue placeholder="Choose VA structure" />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">Standard VA</SelectItem>
<SelectItem value="premium">Premium VA</SelectItem>
<SelectItem value="enterprise">Enterprise VA</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
)}
{/* STEP 5: Documents & Validation */}
{currentStep === 5 && (
<div className="space-y-6">
<h3 className="text-xl font-bold text-[#1C323E]">📂 Upload Compliance Documents</h3>
<div className="space-y-4">
<div className="p-6 bg-white border-2 border-slate-200 rounded-xl">
<Label className="text-slate-700 font-semibold text-lg">W-9 Form *</Label>
<input
type="file"
accept=".pdf,.doc,.docx"
onChange={(e) => e.target.files[0] && handleFileUpload(e.target.files[0], 'w9_document')}
className="block w-full mt-3 text-sm text-slate-500 file:mr-4 file:py-3 file:px-6 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-[#0A39DF] file:text-white hover:file:bg-[#0A39DF]/90"
/>
{vendorData.w9_document && (
<Badge className="mt-3 bg-green-500 text-white px-4 py-2">
<Check className="w-4 h-4 mr-2" />
Uploaded Successfully
</Badge>
)}
</div>
<div className="p-6 bg-white border-2 border-slate-200 rounded-xl">
<Label className="text-slate-700 font-semibold text-lg">Certificate of Insurance (COI) *</Label>
<input
type="file"
accept=".pdf,.doc,.docx"
onChange={(e) => e.target.files[0] && handleFileUpload(e.target.files[0], 'coi_document')}
className="block w-full mt-3 text-sm text-slate-500 file:mr-4 file:py-3 file:px-6 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-[#0A39DF] file:text-white hover:file:bg-[#0A39DF]/90"
/>
{vendorData.coi_document && (
<Badge className="mt-3 bg-green-500 text-white px-4 py-2">
<Check className="w-4 h-4 mr-2" />
Uploaded Successfully
</Badge>
)}
</div>
<div className="p-6 bg-white border-2 border-slate-200 rounded-xl">
<Label className="text-slate-700 font-semibold text-lg">Insurance Expiry Date *</Label>
<Input
type="date"
value={vendorData.insurance_expiry}
onChange={(e) => setVendorData({ ...vendorData, insurance_expiry: e.target.value })}
className="mt-3"
/>
</div>
</div>
</div>
)}
{/* STEP 6: Service & Coverage */}
{currentStep === 6 && (
<div className="space-y-6">
<h3 className="text-xl font-bold text-[#1C323E]">🗺 Service Areas & Coverage</h3>
<div>
<Label className="mb-3 block text-lg font-bold text-slate-700">Coverage Regions *</Label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{["San Francisco Bay Area", "Los Angeles", "Sacramento", "San Diego", "Silicon Valley", "East Bay"].map((region) => (
<label key={region} className="flex items-center gap-3 p-4 border-2 border-slate-200 rounded-xl hover:bg-slate-50 cursor-pointer transition-all hover:border-[#0A39DF]">
<input
type="checkbox"
checked={vendorData.coverage_regions.includes(region)}
onChange={(e) => {
const regions = e.target.checked
? [...vendorData.coverage_regions, region]
: vendorData.coverage_regions.filter((r) => r !== region);
setVendorData({ ...vendorData, coverage_regions: regions });
}}
className="w-5 h-5"
/>
<span className="font-medium">{region}</span>
</label>
))}
</div>
</div>
<div>
<Label className="mb-3 block text-lg font-bold text-slate-700">Eligible Roles *</Label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{["Bartender", "Server", "Cook", "Dishwasher", "Barista", "Cashier", "Security", "Event Staff", "Manager"].map((role) => (
<label key={role} className="flex items-center gap-3 p-4 border-2 border-slate-200 rounded-xl hover:bg-slate-50 cursor-pointer transition-all hover:border-[#0A39DF]">
<input
type="checkbox"
checked={vendorData.eligible_roles.includes(role)}
onChange={(e) => {
const roles = e.target.checked
? [...vendorData.eligible_roles, role]
: vendorData.eligible_roles.filter((r) => r !== role);
setVendorData({ ...vendorData, eligible_roles: roles });
}}
className="w-5 h-5"
/>
<span className="font-medium">{role}</span>
</label>
))}
</div>
</div>
</div>
)}
{/* STEP 7: Rate Proposal */}
{currentStep === 7 && (
<div className="space-y-6">
<h3 className="text-xl font-bold text-[#1C323E]">💰 Rate Proposal</h3>
<div className="p-6 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-amber-900 font-semibold">
Submit your proposed rate structure. Our procurement team will review and may negotiate.
</p>
</div>
<p className="text-slate-600">
You can add detailed rate proposals after initial approval. For now, we'll create a default rate card for review.
</p>
</div>
)}
{/* STEP 8: AI Intelligence */}
{currentStep === 8 && (
<div className="space-y-6">
<h3 className="text-xl font-bold text-[#1C323E]">🤖 AI-Powered Optimization</h3>
<div className="p-8 bg-gradient-to-r from-purple-50 to-blue-50 border-2 border-purple-200 rounded-xl">
<div className="flex items-start gap-4 mb-6">
<Sparkles className="w-12 h-12 text-purple-600" />
<div>
<h4 className="text-2xl font-bold text-purple-900 mb-2">AI Intelligence Features</h4>
<p className="text-purple-700">
Enable AI-powered features to optimize your vendor performance, automate workflows, and gain insights.
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div className="p-4 bg-white rounded-lg border border-purple-200">
<h5 className="font-bold text-slate-900 mb-2">✨ Smart Matching</h5>
<p className="text-sm text-slate-600">AI matches your workforce to the best opportunities</p>
</div>
<div className="p-4 bg-white rounded-lg border border-purple-200">
<h5 className="font-bold text-slate-900 mb-2">📈 Predictive Analytics</h5>
<p className="text-sm text-slate-600">Forecast demand and optimize staffing</p>
</div>
<div className="p-4 bg-white rounded-lg border border-purple-200">
<h5 className="font-bold text-slate-900 mb-2">🎯 Performance Insights</h5>
<p className="text-sm text-slate-600">Real-time performance tracking and recommendations</p>
</div>
<div className="p-4 bg-white rounded-lg border border-purple-200">
<h5 className="font-bold text-slate-900 mb-2">⚡ Automated Workflows</h5>
<p className="text-sm text-slate-600">Streamline operations with AI automation</p>
</div>
</div>
<label className="flex items-center gap-4 p-6 bg-white rounded-xl border-2 border-purple-300 cursor-pointer hover:bg-purple-50">
<input
type="checkbox"
checked={vendorData.ai_optimization_enabled}
onChange={(e) => setVendorData({ ...vendorData, ai_optimization_enabled: e.target.checked })}
className="w-6 h-6"
/>
<div>
<p className="font-bold text-slate-900">Enable AI Intelligence</p>
<p className="text-sm text-slate-600">Activate AI-powered features for your vendor account</p>
</div>
</label>
</div>
{/* Final Review */}
<div className="p-8 bg-green-50 border-2 border-green-300 rounded-xl">
<h4 className="text-2xl font-bold text-green-900 mb-4">✅ Ready to Submit!</h4>
<p className="text-green-800 mb-6">
You've completed all onboarding steps. Review your information and submit for approval.
</p>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex items-center gap-2">
<Check className="w-5 h-5 text-green-600" />
<span>Basic Information Complete</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-5 h-5 text-green-600" />
<span>NDA Signed: {vendorData.nda_signature_date}</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-5 h-5 text-green-600" />
<span>{vendorData.coverage_regions.length} Regions Selected</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-5 h-5 text-green-600" />
<span>{vendorData.eligible_roles.length} Roles Selected</span>
</div>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Navigation Buttons */}
<div className="flex items-center justify-between">
<Button
variant="outline"
onClick={handleBack}
disabled={currentStep === 1}
className="px-8 py-6 text-lg"
>
<ArrowLeft className="w-5 h-5 mr-2" />
Back
</Button>
<div className="flex items-center gap-3">
{currentStep < 8 ? (
<Button
onClick={handleNext}
className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white px-8 py-6 text-lg font-bold shadow-xl"
>
Next Step
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={createVendorMutation.isPending}
className="bg-gradient-to-r from-green-600 to-green-800 hover:from-green-700 hover:to-green-900 text-white px-8 py-6 text-lg font-bold shadow-xl"
>
<Check className="w-5 h-5 mr-2" />
Submit for Approval
</Button>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,509 @@
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Eye, Edit, Copy, UserCheck, Zap, Clock, Users, RefreshCw, Calendar as CalendarIcon, AlertTriangle, List, LayoutGrid, CheckCircle, FileText, X, MapPin } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { format, parseISO, isValid } from "date-fns";
import { Alert, AlertDescription } from "@/components/ui/alert";
import SmartAssignModal from "../components/events/SmartAssignModal";
import { autoFillShifts } from "../components/scheduling/SmartAssignmentEngine";
import { detectAllConflicts, ConflictAlert } from "../components/scheduling/ConflictDetection";
const safeParseDate = (dateString) => {
if (!dateString) return null;
try {
// If date is in format YYYY-MM-DD, parse it without timezone conversion
if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
const [year, month, day] = dateString.split('-').map(Number);
const date = new Date(year, month - 1, day);
return isValid(date) ? date : null;
}
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
return isValid(date) ? date : null;
} catch { return null; }
};
const safeFormatDate = (dateString, formatStr) => {
const date = safeParseDate(dateString);
if (!date) return "-";
try { return format(date, formatStr); } catch { return "-"; }
};
const convertTo12Hour = (time24) => {
if (!time24) return "-";
try {
const [hours, minutes] = time24.split(':');
const hour = parseInt(hours);
const ampm = hour >= 12 ? 'PM' : 'AM';
const hour12 = hour % 12 || 12;
return `${hour12}:${minutes} ${ampm}`;
} catch {
return time24;
}
};
const getStatusBadge = (event, hasConflicts) => {
if (event.is_rapid) {
return (
<div className="relative inline-flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md">
<Zap className="w-3.5 h-3.5 fill-white" />
RAPID
{hasConflicts && (
<AlertTriangle className="w-3 h-3 absolute -top-1 -right-1 text-orange-500 bg-white rounded-full p-0.5" />
)}
</div>
);
}
const statusConfig = {
'Draft': { bg: 'bg-slate-500', icon: FileText },
'Pending': { bg: 'bg-amber-500', icon: Clock },
'Partial Staffed': { bg: 'bg-orange-500', icon: AlertTriangle },
'Fully Staffed': { bg: 'bg-emerald-500', icon: CheckCircle },
'Active': { bg: 'bg-blue-500', icon: Users },
'Completed': { bg: 'bg-slate-400', icon: CheckCircle },
'Canceled': { bg: 'bg-red-500', icon: X },
};
const config = statusConfig[event.status] || { bg: 'bg-slate-400', icon: Clock };
const Icon = config.icon;
return (
<div className={`relative inline-flex items-center gap-2 ${config.bg} text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md`}>
<Icon className="w-3.5 h-3.5" />
{event.status}
{hasConflicts && (
<AlertTriangle className="w-3 h-3 absolute -top-1 -right-1 text-orange-500 bg-white rounded-full p-0.5" />
)}
</div>
);
};
export default function VendorOrders() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState("");
const [activeTab, setActiveTab] = useState("all");
const [viewMode, setViewMode] = useState("table");
const [showConflicts, setShowConflicts] = useState(true);
const [assignModal, setAssignModal] = useState({ open: false, event: null });
const [assignmentOptions] = useState({
prioritizeSkill: true,
prioritizeReliability: true,
prioritizeVendor: true,
prioritizeFatigue: true,
prioritizeCompliance: true,
prioritizeProximity: true,
prioritizeCost: false,
});
const { data: user } = useQuery({
queryKey: ['current-user-vendor-orders'],
queryFn: () => base44.auth.me(),
});
const { data: allEvents = [] } = useQuery({
queryKey: ['all-events-vendor'],
queryFn: () => base44.entities.Event.list('-date'),
});
const { data: allStaff = [] } = useQuery({
queryKey: ['staff-for-auto-assign'],
queryFn: () => base44.entities.Staff.list(),
});
const { data: vendorRates = [] } = useQuery({
queryKey: ['vendor-rates-auto-assign'],
queryFn: () => base44.entities.VendorRate.list(),
initialData: [],
});
const updateEventMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Event.update(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['all-events-vendor'] }),
});
const autoAssignMutation = useMutation({
mutationFn: async (event) => {
const assignments = await autoFillShifts(event, allStaff, vendorEvents, vendorRates, assignmentOptions);
if (assignments.length === 0) throw new Error("No suitable staff found");
const updatedAssignedStaff = [...(event.assigned_staff || []), ...assignments];
const updatedShifts = (event.shifts || []).map(shift => {
const updatedRoles = (shift.roles || []).map(role => {
const roleAssignments = assignments.filter(a => a.role === role.role);
return { ...role, assigned: (role.assigned || 0) + roleAssignments.length };
});
return { ...shift, roles: updatedRoles };
});
const totalRequested = updatedShifts.reduce((accShift, shift) => {
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
}, 0);
const totalAssigned = updatedAssignedStaff.length;
let newStatus = event.status;
if (totalAssigned >= totalRequested && totalRequested > 0) {
newStatus = 'Fully Staffed';
} else if (totalAssigned > 0 && totalAssigned < totalRequested) {
newStatus = 'Partial Staffed';
} else if (totalAssigned === 0) {
newStatus = 'Pending';
}
await base44.entities.Event.update(event.id, {
assigned_staff: updatedAssignedStaff,
shifts: updatedShifts,
requested: (event.requested || 0) + assignments.length,
status: newStatus,
});
return assignments.length;
},
onSuccess: (count) => {
queryClient.invalidateQueries({ queryKey: ['all-events-vendor'] });
toast({ title: "✅ Auto-Assigned", description: `Assigned ${count} staff automatically` });
},
onError: (error) => {
toast({ title: "⚠️ Auto-Assign Failed", description: error.message, variant: "destructive" });
},
});
const vendorEvents = useMemo(() => {
return allEvents.filter(e =>
e.vendor_name === user?.company_name ||
e.vendor_id === user?.id ||
e.created_by === user?.email
);
}, [allEvents, user]);
const eventsWithConflicts = useMemo(() => {
return vendorEvents.map(event => {
const conflicts = detectAllConflicts(event, vendorEvents);
return { ...event, detected_conflicts: conflicts };
});
}, [vendorEvents]);
const totalConflicts = eventsWithConflicts.reduce((sum, e) => sum + (e.detected_conflicts?.length || 0), 0);
const filteredEvents = useMemo(() => {
let filtered = eventsWithConflicts;
if (activeTab === "upcoming") filtered = filtered.filter(e => { const eventDate = safeParseDate(e.date); return eventDate && eventDate > new Date(); });
else if (activeTab === "active") filtered = filtered.filter(e => e.status === "Active");
else if (activeTab === "past") filtered = filtered.filter(e => e.status === "Completed");
else if (activeTab === "conflicts") filtered = filtered.filter(e => e.detected_conflicts && e.detected_conflicts.length > 0);
if (searchTerm) {
const lower = searchTerm.toLowerCase();
filtered = filtered.filter(e =>
e.event_name?.toLowerCase().includes(lower) ||
e.business_name?.toLowerCase().includes(lower) ||
e.hub?.toLowerCase().includes(lower)
);
}
return filtered;
}, [eventsWithConflicts, searchTerm, activeTab]);
const getAssignmentStatus = (event) => {debugger;
const totalRequested = event.shifts?.reduce((accShift, shift) => {
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
}, 0) || 0;
const assigned = event.assigned_staff?.length || 0;
const fillPercent = totalRequested > 0 ? Math.round((assigned / totalRequested) * 100) : 0;
if (assigned === 0) return { color: 'bg-slate-100 text-slate-600', text: '0', percent: '0%', status: 'empty' };
if (totalRequested > 0 && assigned >= totalRequested) return { color: 'bg-emerald-500 text-white', text: assigned, percent: '100%', status: 'full' };
if (totalRequested > 0 && assigned < totalRequested) return { color: 'bg-orange-500 text-white', text: assigned, percent: `${fillPercent}%`, status: 'partial' };
return { color: 'bg-slate-500 text-white', text: assigned, percent: '0%', status: 'partial' };
};
const getTabCount = (tab) => {
if (tab === "all") return vendorEvents.length;
if (tab === "conflicts") return eventsWithConflicts.filter(e => e.detected_conflicts && e.detected_conflicts.length > 0).length;
if (tab === "upcoming") return vendorEvents.filter(e => { const eventDate = safeParseDate(e.date); return eventDate && eventDate > new Date(); }).length;
if (tab === "active") return vendorEvents.filter(e => e.status === "Active").length;
if (tab === "past") return vendorEvents.filter(e => e.status === "Completed").length;
return 0;
};
// The original handleAutoAssignEvent is removed as the button now opens the modal directly.
// const handleAutoAssignEvent = (event) => autoAssignMutation.mutate(event);
const getEventTimes = (event) => {
const firstShift = event.shifts?.[0];
const rolesInFirstShift = firstShift?.roles || [];
let startTime = null;
let endTime = null;
if (rolesInFirstShift.length > 0) {
startTime = rolesInFirstShift[0].start_time || null;
endTime = rolesInFirstShift[0].end_time || null;
}
return {
startTime: startTime ? convertTo12Hour(startTime) : "-",
endTime: endTime ? convertTo12Hour(endTime) : "-"
};
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-[1800px] mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900">Order Management</h1>
<p className="text-sm text-slate-500 mt-1">View, assign, and track all your orders</p>
</div>
{showConflicts && totalConflicts > 0 && (
<Alert className="mb-6 border-2 border-orange-500 bg-orange-50">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-orange-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<AlertDescription className="font-semibold text-orange-900">
{totalConflicts} scheduling conflict{totalConflicts !== 1 ? 's' : ''} detected
</AlertDescription>
</div>
<Button variant="ghost" size="icon" onClick={() => setShowConflicts(false)} className="flex-shrink-0">
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</Alert>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="border border-red-200 bg-red-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-500 rounded-lg flex items-center justify-center">
<Zap className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-red-600 font-semibold uppercase">RAPID</p>
<p className="text-2xl font-bold text-red-700">{vendorEvents.filter(e => e.is_rapid).length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-amber-200 bg-amber-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
<Clock className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-amber-600 font-semibold uppercase">REQUESTED</p>
<p className="text-2xl font-bold text-amber-700">{vendorEvents.filter(e => e.status === 'Pending' || e.status === 'Draft').length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-orange-200 bg-orange-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-orange-600 font-semibold uppercase">PARTIAL</p>
<p className="text-2xl font-bold text-orange-700">{vendorEvents.filter(e => {
const status = getAssignmentStatus(e);
return status.status === 'partial';
}).length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-emerald-200 bg-emerald-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-emerald-500 rounded-lg flex items-center justify-center">
<CheckCircle className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-emerald-600 font-semibold uppercase">FULLY STAFFED</p>
<p className="text-2xl font-bold text-emerald-700">{vendorEvents.filter(e => {
const status = getAssignmentStatus(e);
return status.status === 'full';
}).length}</p>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="bg-white rounded-xl p-4 mb-6 flex items-center gap-4 border shadow-sm">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input placeholder="Search by event, business, or location..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="pl-10 border-slate-200 h-10" />
</div>
<div className="flex items-center gap-2">
<Button variant={viewMode === "table" ? "default" : "outline"} size="sm" onClick={() => setViewMode("table")} className={viewMode === "table" ? "bg-[#0A39DF]" : ""}>
<List className="w-4 h-4" />
</Button>
<Button variant={viewMode === "scheduler" ? "default" : "outline"} size="sm" onClick={() => setViewMode("scheduler")} className={viewMode === "scheduler" ? "bg-[#0A39DF]" : ""}>
<LayoutGrid className="w-4 h-4" />
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
<TabsList className="bg-white border">
<TabsTrigger value="all">All ({getTabCount("all")})</TabsTrigger>
<TabsTrigger value="conflicts" className="data-[state=active]:bg-orange-500 data-[state=active]:text-white">
<AlertTriangle className="w-4 h-4 mr-2" />
Conflicts ({getTabCount("conflicts")})
</TabsTrigger>
<TabsTrigger value="upcoming">Upcoming ({getTabCount("upcoming")})</TabsTrigger>
<TabsTrigger value="active">Active ({getTabCount("active")})</TabsTrigger>
<TabsTrigger value="past">Past ({getTabCount("past")})</TabsTrigger>
</TabsList>
</Tabs>
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50 border-b">
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide h-10">BUSINESS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">HUB</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">EVENT</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">DATE & TIME</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">STATUS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">REQUESTED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ASSIGNED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">INVOICE</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredEvents.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center py-12 text-slate-500"><CalendarIcon className="w-12 h-12 mx-auto mb-3 text-slate-300" /><p className="font-medium">No orders found</p></TableCell></TableRow>
) : (
filteredEvents.map((event) => {
const assignmentStatus = getAssignmentStatus(event);
const showAutoButton = assignmentStatus.status !== 'full' && event.status !== 'Canceled' && event.status !== 'Completed';
const hasConflicts = event.detected_conflicts && event.detected_conflicts.length > 0;
const eventTimes = getEventTimes(event);
const eventDate = safeParseDate(event.date);
const dayOfWeek = eventDate ? format(eventDate, 'EEEE') : '';
const invoiceReady = event.status === "Completed";
return (
<React.Fragment key={event.id}>
<TableRow className="hover:bg-slate-50 transition-colors border-b">
<TableCell className="py-3">
<p className="text-sm text-slate-700 font-medium">{event.business_name || "—"}</p>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center gap-1.5 text-sm text-slate-500">
<MapPin className="w-3.5 h-3.5" />
{event.hub || event.event_location || "Main Hub"}
</div>
</TableCell>
<TableCell className="py-3">
<p className="font-semibold text-slate-900 text-sm">{event.event_name}</p>
</TableCell>
<TableCell className="py-3">
<div className="space-y-0.5">
<p className="text-sm text-slate-900 font-semibold">{eventDate ? format(eventDate, 'MM.dd.yyyy') : '-'}</p>
<p className="text-xs text-slate-500">{dayOfWeek}</p>
<div className="flex items-center gap-1 text-xs text-slate-600 mt-1">
<Clock className="w-3 h-3" />
<span>{eventTimes.startTime} - {eventTimes.endTime}</span>
</div>
</div>
</TableCell>
<TableCell className="py-3">
{getStatusBadge(event, hasConflicts)}
</TableCell>
<TableCell className="text-center py-3">
<span className="font-semibold text-slate-700 text-sm">{event.requested || 0}</span>
</TableCell>
<TableCell className="text-center py-3">
<div className="flex flex-col items-center gap-1">
<Badge className={`${assignmentStatus.color} font-bold px-3 py-1 rounded-full text-xs`}>
{assignmentStatus.text}
</Badge>
<span className="text-[10px] text-slate-500 font-medium">{assignmentStatus.percent}</span>
</div>
</TableCell>
<TableCell className="text-center py-3">
<div className="flex items-center justify-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${invoiceReady ? 'bg-blue-100' : 'bg-slate-100'}`}>
<FileText className={`w-5 h-5 ${invoiceReady ? 'text-blue-600' : 'text-slate-400'}`} />
</div>
</div>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center justify-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => setAssignModal({ open: true, event: event })}
className="h-8 px-2 hover:bg-slate-100"
title="Smart Assign"
>
<UserCheck className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EventDetail?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="View"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="Edit"
>
<Edit className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
{hasConflicts && activeTab === "conflicts" && (
<TableRow>
<TableCell colSpan={9} className="bg-orange-50/50 py-4">
<ConflictAlert conflicts={event.detected_conflicts} />
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})
)}
</TableBody>
</Table>
</div>
</div>
<SmartAssignModal
open={assignModal.open}
onClose={() => setAssignModal({ open: false, event: null })}
event={assignModal.event}
/>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { TrendingUp, Award, Users, Star, Calendar, Target } from "lucide-react";
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
export default function VendorPerformance() {
const performanceData = [
{ month: 'Jan', fillRate: 94, satisfaction: 4.6, onTime: 96 },
{ month: 'Feb', fillRate: 96, satisfaction: 4.7, onTime: 97 },
{ month: 'Mar', fillRate: 95, satisfaction: 4.8, onTime: 98 },
{ month: 'Apr', fillRate: 97, satisfaction: 4.7, onTime: 96 },
{ month: 'May', fillRate: 98, satisfaction: 4.9, onTime: 99 },
];
const metrics = [
{ label: "Fill Rate", value: "97%", icon: Target, color: "text-green-600", bg: "bg-green-100" },
{ label: "Client Satisfaction", value: "4.8/5", icon: Star, color: "text-amber-600", bg: "bg-amber-100" },
{ label: "On-Time Delivery", value: "98%", icon: Calendar, color: "text-blue-600", bg: "bg-blue-100" },
{ label: "Active Staff", value: "156", icon: Users, color: "text-purple-600", bg: "bg-purple-100" },
];
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold text-[#1C323E]">Performance Metrics</h1>
<p className="text-slate-500 mt-1">Track your vendor performance and ratings</p>
</div>
{/* KROW Score */}
<Card className="mb-8 border-2 border-[#0A39DF] bg-gradient-to-br from-blue-50 to-white">
<CardContent className="p-8 text-center">
<Award className="w-16 h-16 mx-auto text-[#0A39DF] mb-4" />
<h2 className="text-4xl font-bold text-[#1C323E] mb-2">KROW Score: A+</h2>
<p className="text-slate-600 mb-4">You're in the top 10% of vendors!</p>
<Badge className="bg-green-100 text-green-700 text-lg px-4 py-2">
<TrendingUp className="w-4 h-4 mr-2" />
+5% from last month
</Badge>
</CardContent>
</Card>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
{metrics.map((metric, index) => (
<Card key={index} className="border-slate-200">
<CardContent className="p-6">
<div className={`w-12 h-12 ${metric.bg} rounded-lg flex items-center justify-center mb-3`}>
<metric.icon className={`w-6 h-6 ${metric.color}`} />
</div>
<p className="text-sm text-slate-500">{metric.label}</p>
<p className={`text-3xl font-bold ${metric.color}`}>{metric.value}</p>
</CardContent>
</Card>
))}
</div>
{/* Performance Trends */}
<Card className="mb-8 border-slate-200">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle>Performance Trends</CardTitle>
</CardHeader>
<CardContent className="p-6">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={performanceData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="fillRate" stroke="#0A39DF" strokeWidth={3} name="Fill Rate %" />
<Line type="monotone" dataKey="onTime" stroke="#10b981" strokeWidth={3} name="On-Time %" />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Client Feedback */}
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle>Recent Client Feedback</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{[
{ client: "Tech Corp", rating: 5, comment: "Exceptional service and professional staff", date: "2 days ago" },
{ client: "Premier Events", rating: 4, comment: "Very reliable, minor delay on one shift", date: "1 week ago" },
{ client: "Corporate Solutions", rating: 5, comment: "Best vendor we've worked with!", date: "2 weeks ago" },
].map((feedback, index) => (
<div key={index} className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-semibold text-[#1C323E]">{feedback.client}</h4>
<div className="flex items-center gap-1 mt-1">
{[...Array(5)].map((_, i) => (
<Star key={i} className={`w-4 h-4 ${i < feedback.rating ? 'text-amber-400 fill-amber-400' : 'text-slate-300'}`} />
))}
</div>
</div>
<span className="text-xs text-slate-500">{feedback.date}</span>
</div>
<p className="text-sm text-slate-600">{feedback.comment}</p>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,776 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import {
Plus, Edit, Trash2, TrendingUp, TrendingDown, DollarSign, Users, Calculator,
CheckCircle2, Save, Building2, Settings, ChevronDown, ChevronUp, Loader2, Sparkles, Search, MapPin, Briefcase
} from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
const MINIMUM_WAGE = 16.50;
const CATEGORIES = [
"Kitchen and Culinary",
"Concessions",
"Facilities",
"Bartending",
"Security",
"Event Staff",
"Management",
"Technical",
"Other"
];
const VENDORS = [
{ name: "Legendary Event Staffing", region: "Bay Area", state: "California", city: "San Francisco", specialty: "Full Service Events", markup: 25, fee: 20, csat: 4.8 },
{ name: "Instawork", region: "National", state: "Multiple", city: "Multiple", specialty: "On-Demand Gig Platform", markup: 18, fee: 15, csat: 4.5 },
{ name: "The Party Staff", region: "Bay Area", state: "California", city: "San Francisco", specialty: "Event Staffing", markup: 24, fee: 19, csat: 4.2 },
{ name: "Elite Hospitality Staffing", region: "Bay Area", state: "California", city: "Oakland", specialty: "Hotels/Events", markup: 23, fee: 18, csat: 4.3 },
{ name: "Jeff Duerson Staffing", region: "Bay Area", state: "California", city: "San Jose", specialty: "Catering/Events", markup: 27, fee: 22, csat: 4.0 },
{ name: "Flagship Culinary Services", region: "Bay Area", state: "California", city: "Palo Alto", specialty: "Culinary", markup: 26, fee: 21, csat: 4.1 },
{ name: "LA Event Professionals", region: "Southern California", state: "California", city: "Los Angeles", specialty: "Event Staffing", markup: 22, fee: 18, csat: 4.4 },
{ name: "Hollywood Hospitality", region: "Southern California", state: "California", city: "Los Angeles", specialty: "Full Service Events", markup: 20, fee: 17, csat: 4.6 },
{ name: "San Diego Event Staff", region: "Southern California", state: "California", city: "San Diego", specialty: "Hospitality", markup: 21, fee: 17, csat: 4.3 },
{ name: "OC Staffing Solutions", region: "Southern California", state: "California", city: "Irvine", specialty: "Event-based staffing", markup: 25, fee: 20, csat: 4.1 },
];
const MARKET_AVERAGES = {
"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": 42.25
};
const ROLE_CATEGORIES = {
"Banquet Captain": "Event Staff",
"Barback": "Bartending",
"Barista": "Concessions",
"Busser": "Event Staff",
"BW Bartender": "Bartending",
"Cashier/Standworker": "Concessions",
"Cook": "Kitchen and Culinary",
"Dinning Attendant": "Event Staff",
"Dishwasher/ Steward": "Kitchen and Culinary",
"Executive Chef": "Management",
"FOH Cafe Attendant": "Concessions",
"Full Bartender": "Bartending",
"Grill Cook": "Kitchen and Culinary",
"Host/Hostess/Greeter": "Event Staff",
"Internal Support": "Other",
"Lead Cook": "Kitchen and Culinary",
"Line Cook": "Kitchen and Culinary",
"Premium Server": "Event Staff",
"Prep Cook": "Kitchen and Culinary",
"Receiver": "Facilities",
"Server": "Event Staff",
"Sous Chef": "Management",
"Warehouse Worker": "Facilities",
"Baker": "Kitchen and Culinary",
"Janitor": "Facilities",
"Mixologist": "Bartending",
"Utilities": "Facilities",
"Scullery": "Kitchen and Culinary",
"Runner": "Event Staff",
"Pantry Cook": "Kitchen and Culinary",
"Supervisor": "Management",
"Steward": "Kitchen and Culinary",
"Steward Supervisor": "Management"
};
export default function VendorRateCard() {
const [selectedVendor, setSelectedVendor] = useState("all");
const [activeCategory, setActiveCategory] = useState("all");
const [expandedVendors, setExpandedVendors] = useState({});
const [generatingDemoData, setGeneratingDemoData] = useState(false);
// NEW: Search and filter states
const [searchVendor, setSearchVendor] = useState("");
const [searchLocation, setSearchLocation] = useState("");
const [searchState, setSearchState] = useState("");
const [searchPosition, setSearchPosition] = useState("");
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: user } = useQuery({
queryKey: ['current-user-vendor-rates'],
queryFn: () => base44.auth.me(),
});
const userRole = user?.user_role || user?.role || "admin";
const isVendor = userRole === "vendor";
const isAdminOrProcurement = userRole === "admin" || userRole === "procurement";
const calculateClientRate = (wage, markupPercent, feePercent) => {
const baseWage = parseFloat(wage);
const markupAmount = baseWage * (parseFloat(markupPercent) / 100);
const subtotal = baseWage + markupAmount;
const feeAmount = subtotal * (parseFloat(feePercent) / 100);
return (subtotal + feeAmount).toFixed(2);
};
const analyzePricing = (role, clientRate) => {
const marketAvg = MARKET_AVERAGES[role];
if (!marketAvg) return { status: "competitive", message: "No market data", color: "text-blue-600", bg: "bg-blue-50", icon: CheckCircle2 };
const diff = ((clientRate - marketAvg) / marketAvg) * 100;
if (diff < -15) return { status: "underpriced", message: `${Math.abs(diff).toFixed(0)}% below market`, color: "text-red-600", bg: "bg-red-50", icon: TrendingDown };
if (diff > 20) return { status: "overpriced", message: `${diff.toFixed(0)}% above market`, color: "text-orange-600", bg: "bg-orange-50", icon: TrendingUp };
if (diff >= -5 && diff <= 10) return { status: "optimal", message: "Optimal pricing", color: "text-green-600", bg: "bg-green-50", icon: CheckCircle2 };
return { status: "competitive", message: "Competitive", color: "text-blue-600", bg: "bg-blue-50", icon: CheckCircle2 };
};
// Generate rates for ALL vendors
const generateAllVendorRates = async () => {
setGeneratingDemoData(true);
const allRoles = Object.keys(MARKET_AVERAGES);
const totalRates = (VENDORS.length - 1) * allRoles.length; // -1 for Legendary which already has rates
toast({
title: "🚀 Generating All Vendor Rates",
description: `Creating ${totalRates} rate cards for ${VENDORS.length - 1} vendors...`,
});
try {
let totalCreated = 0;
for (const vendor of VENDORS) {
// Skip Legendary since it already has rates
if (vendor.name === "Legendary Event Staffing") continue;
for (const [roleName, marketRate] of Object.entries(MARKET_AVERAGES)) {
const category = ROLE_CATEGORIES[roleName] || "Other";
// Calculate employee wage based on region
let baseWageMultiplier = 0.65; // Default 65% of market
if (vendor.region === "Bay Area") baseWageMultiplier = 0.70;
else if (vendor.region === "Southern California") baseWageMultiplier = 0.67;
else if (vendor.region === "National") baseWageMultiplier = 0.62;
const employeeWage = Math.max(MINIMUM_WAGE, marketRate * baseWageMultiplier);
const clientRate = calculateClientRate(employeeWage, vendor.markup, vendor.fee);
const clientRateNum = parseFloat(clientRate);
// Determine competitive status (within ±15% of market)
const diff = ((clientRateNum - marketRate) / marketRate) * 100;
const isCompetitive = diff >= -15 && diff <= 15;
// CSTA compliance (CA vendors with proper fee structure)
const isCSTA = vendor.state === "California" && vendor.fee <= 25;
// Compliance verified (wage meets minimum + has proper documentation)
const isCompliant = employeeWage >= MINIMUM_WAGE; // This is directly related to minimum_wage_compliance
await base44.entities.VendorRate.create({
vendor_name: vendor.name,
category: category,
role_name: roleName,
employee_wage: parseFloat(employeeWage.toFixed(2)),
markup_percentage: vendor.markup,
vendor_fee_percentage: vendor.fee,
client_rate: clientRateNum,
is_active: true,
minimum_wage_compliance: employeeWage >= MINIMUM_WAGE,
pricing_status: analyzePricing(roleName, clientRateNum).status,
market_average: marketRate,
notes: `${vendor.region} - ${vendor.specialty}`,
client_visibility: "all",
competitive_status: isCompetitive,
csta_compliant: isCSTA,
compliance_verified: isCompliant
});
totalCreated++;
}
}
queryClient.invalidateQueries({ queryKey: ['vendor-rates'] });
toast({
title: "✅ All Vendor Rates Generated!",
description: `Created ${totalCreated} rate cards across ${VENDORS.length - 1} vendors (${allRoles.length} services each)`,
});
} catch (error) {
console.error("Error generating vendor rates:", error);
toast({
title: "Error Generating Rates",
description: error.message || "Something went wrong",
variant: "destructive",
});
} finally {
setGeneratingDemoData(false);
}
};
const { data: rates = [], isLoading } = useQuery({
queryKey: ['vendor-rates', selectedVendor, userRole, user?.company_name],
queryFn: async () => {
const allRates = await base44.entities.VendorRate.list();
if (isVendor && user?.company_name) {
return allRates.filter(r => r.vendor_name === user.company_name);
}
if (selectedVendor === "all") return allRates;
return allRates.filter(r => r.vendor_name === selectedVendor);
},
});
const { data: vendorSettings = [] } = useQuery({
queryKey: ['vendor-default-settings'],
queryFn: async () => {
return await base44.entities.VendorDefaultSettings.list();
},
initialData: [],
});
const toggleVendorExpanded = (vendorName) => {
setExpandedVendors(prev => ({
...prev,
[vendorName]: !prev[vendorName]
}));
};
const ratesByVendor = rates.reduce((acc, rate) => {
if (!acc[rate.vendor_name]) acc[rate.vendor_name] = [];
acc[rate.vendor_name].push(rate);
return acc;
}, {});
// UPDATED: Enhanced filtering with search including state
const filteredRatesByVendor = Object.entries(ratesByVendor).reduce((acc, [vendorName, vendorRates]) => {
// Filter by vendor name search
if (searchVendor.trim() && !vendorName.toLowerCase().includes(searchVendor.toLowerCase())) {
return acc;
}
// Filter by location search (check vendor location from VENDORS list)
const vendorInfo = VENDORS.find(v => v.name === vendorName);
if (searchLocation.trim() && vendorInfo) {
const locationMatch =
vendorInfo.region?.toLowerCase().includes(searchLocation.toLowerCase()) ||
vendorInfo.city?.toLowerCase().includes(searchLocation.toLowerCase());
if (!locationMatch) {
return acc;
}
}
// Filter by state search
if (searchState.trim() && vendorInfo) {
const stateMatch = vendorInfo.state?.toLowerCase().includes(searchState.toLowerCase());
if (!stateMatch) {
return acc;
}
}
// Filter by category
let filtered = activeCategory === "all"
? vendorRates
: vendorRates.filter(r => r.category === activeCategory);
// Filter by position/role search
if (searchPosition.trim()) {
filtered = filtered.filter(r =>
r.role_name?.toLowerCase().includes(searchPosition.toLowerCase())
);
}
if (filtered.length > 0) {
acc[vendorName] = filtered;
}
return acc;
}, {});
const clearAllFilters = () => {
setSearchVendor("");
setSearchLocation("");
setSearchState("");
setSearchPosition("");
setActiveCategory("all");
setSelectedVendor("all");
};
const hasActiveFilters = searchVendor || searchLocation || searchState || searchPosition || activeCategory !== "all";
return (
<div className="p-4 md:p-8 bg-gradient-to-br from-slate-50 to-slate-100 min-h-screen">
<div className="max-w-7xl mx-auto">
<style>{`
.toggle-blue[data-state="checked"] {
background-color: #0A39DF !important;
}
`}</style>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-[#1C323E]">Vendor Rate Cards</h1>
<p className="text-slate-600 mt-1">Manage vendor rates, markup, and pricing across all locations</p>
</div>
<div className="flex gap-2">
{isAdminOrProcurement && Object.keys(ratesByVendor).length < 5 && (
<Button
onClick={generateAllVendorRates}
disabled={generatingDemoData}
className="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white shadow-lg"
>
{generatingDemoData ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Generating...
</>
) : (
<>
<Sparkles className="w-5 h-5 mr-2" />
Generate All Vendor Rates
</>
)}
</Button>
)}
</div>
</div>
{/* UPDATED: Search & Filter Section - Now with 4 fields */}
<Card className="border-slate-200 mb-6">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<Label className="text-sm font-semibold text-slate-700">Search & Filter</Label>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="text-slate-500 hover:text-slate-700"
>
Clear All Filters
</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="search-vendor" className="text-xs text-slate-600">Search by Vendor</Label>
<div className="relative">
<Building2 className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
id="search-vendor"
type="text"
placeholder="Enter vendor name..."
value={searchVendor}
onChange={(e) => setSearchVendor(e.target.value)}
className="pl-10 border-slate-300"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="search-location" className="text-xs text-slate-600">Search by Region</Label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
id="search-location"
type="text"
placeholder="Bay Area, LA, National..."
value={searchLocation}
onChange={(e) => setSearchLocation(e.target.value)}
className="pl-10 border-slate-300"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="search-state" className="text-xs text-slate-600">Search by State</Label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
id="search-state"
type="text"
placeholder="California, Multiple..."
value={searchState}
onChange={(e) => setSearchState(e.target.value)}
className="pl-10 border-slate-300"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="search-position" className="text-xs text-slate-600">Search by Position</Label>
<div className="relative">
<Briefcase className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
id="search-position"
type="text"
placeholder="Cook, Server, Bartender..."
value={searchPosition}
onChange={(e) => setSearchPosition(e.target.value)}
className="pl-10 border-slate-300"
/>
</div>
</div>
</div>
{/* Search Results Summary */}
{hasActiveFilters && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-900">
<Search className="w-4 h-4 inline mr-2" />
Found <strong>{Object.keys(filteredRatesByVendor).length}</strong> vendors with <strong>{Object.values(filteredRatesByVendor).flat().length}</strong> total services
</p>
</div>
)}
</CardContent>
</Card>
{/* Summary Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="border-slate-200">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-600">Total Vendors</p>
<p className="text-2xl font-bold text-[#1C323E]">{Object.keys(filteredRatesByVendor).length}</p>
</div>
<Building2 className="w-8 h-8 text-[#0A39DF]" />
</div>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-600">Total Services</p>
<p className="text-2xl font-bold text-[#1C323E]">
{Object.values(filteredRatesByVendor).flat().length}
</p>
</div>
<Users className="w-8 h-8 text-emerald-600" />
</div>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-600">Competitive Rates</p>
<p className="text-2xl font-bold text-[#1C323E]">
{Object.values(filteredRatesByVendor).flat().filter(r => r.competitive_status).length}
</p>
</div>
<TrendingUp className="w-8 h-8 text-green-600" />
</div>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-600">Avg Partner Rate</p>
<p className="text-2xl font-bold text-[#1C323E]">
${(Object.values(filteredRatesByVendor).flat().reduce((sum, r) => sum + parseFloat(r.client_rate || 0), 0) / (Object.values(filteredRatesByVendor).flat().length || 1)).toFixed(2)}
</p>
</div>
<DollarSign className="w-8 h-8 text-blue-600" />
</div>
</CardContent>
</Card>
</div>
{/* Category Tabs */}
<div className="mb-6">
<Card className="border-slate-200">
<CardContent className="p-4">
<Label className="text-sm font-medium mb-3 block text-slate-700">Service Category</Label>
<div className="flex flex-wrap gap-2">
<button
onClick={() => setActiveCategory("all")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
activeCategory === "all"
? "bg-[#0A39DF] text-white shadow-md"
: "bg-white text-slate-600 border border-slate-200 hover:border-[#0A39DF] hover:text-[#0A39DF]"
}`}
>
All Services
</button>
{CATEGORIES.map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
activeCategory === cat
? "bg-[#0A39DF] text-white shadow-md"
: "bg-white text-slate-600 border border-slate-200 hover:border-[#0A39DF] hover:text-[#0A39DF]"
}`}
>
{cat}
</button>
))}
</div>
</CardContent>
</Card>
</div>
{/* Vendor Cards */}
<div className="space-y-4">
{Object.keys(filteredRatesByVendor).length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
{hasActiveFilters ? (
<>
<Search className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-900 mb-2">No Matching Results</h3>
<p className="text-slate-600 mb-6">Try adjusting your search filters</p>
<Button onClick={clearAllFilters} variant="outline">
Clear All Filters
</Button>
</>
) : (
<>
<Building2 className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-900 mb-2">No Rates Found</h3>
<p className="text-slate-600 mb-6">Generate rates for all vendors to get started</p>
{isAdminOrProcurement && (
<Button
onClick={generateAllVendorRates}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
disabled={generatingDemoData}
>
{generatingDemoData ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Generating...
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
Generate All Vendor Rates
</>
)}
</Button>
)}
</>
)}
</div>
) : (
Object.entries(filteredRatesByVendor).map(([vendorName, vendorRates]) => {
const vendorDefaults = vendorSettings.find(s => s.vendor_name === vendorName);
const vendorInfo = VENDORS.find(v => v.name === vendorName);
const isExpanded = expandedVendors[vendorName];
// Calculate vendor-level tags
const competitiveCount = vendorRates.filter(r => r.competitive_status).length;
const cstaCompliantCount = vendorRates.filter(r => r.csta_compliant).length;
const complianceCount = vendorRates.filter(r => r.compliance_verified).length;
const totalRates = vendorRates.length;
return (
<Card key={vendorName} className="border-slate-200 shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100 p-4">
<div className="flex items-center justify-between">
<div
className="flex items-center gap-3 flex-1 cursor-pointer"
onClick={() => toggleVendorExpanded(vendorName)}
>
<div className="w-12 h-12 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center flex-shrink-0">
<Building2 className="w-6 h-6 text-white" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<CardTitle className="text-base font-semibold text-[#1C323E]">{vendorName}</CardTitle>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-slate-400 flex-shrink-0" />
) : (
<ChevronDown className="w-4 h-4 text-slate-400 flex-shrink-0" />
)}
</div>
{vendorInfo && (
<p className="text-xs text-slate-500 mb-2">{vendorInfo.city}, {vendorInfo.state} {vendorInfo.specialty}</p>
)}
<div className="flex items-center gap-2 mt-2 flex-wrap">
{vendorDefaults && (
<>
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200 text-xs font-medium">
<TrendingUp className="w-3 h-3 mr-1" />
Markup: {vendorDefaults.default_markup_percentage}%
</Badge>
<Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-200 text-xs font-medium">
<DollarSign className="w-3 h-3 mr-1" />
VA: {vendorDefaults.default_vendor_fee_percentage}%
</Badge>
</>
)}
{totalRates > 0 && complianceCount === totalRates && (
<Badge className="bg-green-100 text-green-700 text-xs font-medium border-0">
<CheckCircle2 className="w-3 h-3 mr-1" />
Full Compliance
</Badge>
)}
{totalRates > 0 && competitiveCount / totalRates >= 0.8 && (
<Badge className="bg-blue-100 text-blue-700 text-xs font-medium border-0">
<TrendingUp className="w-3 h-3 mr-1" />
Competitive
</Badge>
)}
{vendorInfo && vendorInfo.csat && (
<Badge className="bg-emerald-100 text-emerald-700 text-xs font-medium border-0">
CSAT {vendorInfo.csat.toFixed(1)}
</Badge>
)}
<span className="text-xs text-slate-500 font-medium">
{vendorRates.length} {vendorRates.length === 1 ? 'service' : 'services'}
</span>
</div>
</div>
</div>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="p-6">
<div className="space-y-3">
{vendorRates.map(rate => {
const analysis = analyzePricing(rate.role_name, rate.client_rate);
const Icon = analysis.icon;
const baseWage = rate.employee_wage;
const markupAmount = baseWage * (rate.markup_percentage / 100);
const subtotal = baseWage + markupAmount;
const feeAmount = subtotal * (rate.vendor_fee_percentage / 100);
return (
<div
key={rate.id}
className="flex items-center justify-between p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-[#0A39DF] transition-all"
>
<div className="flex items-center gap-4 flex-1">
<div className="w-48">
<p className="font-semibold text-[#1C323E]">{rate.role_name}</p>
<p className="text-xs text-slate-500">$/hourly</p>
<div className="flex items-center gap-1 mt-1 flex-wrap">
<Badge variant="outline" className="text-[10px]">
{rate.category}
</Badge>
{rate.competitive_status && (
<Badge className="bg-green-100 text-green-700 text-[10px] h-auto px-2 py-0.5">
Competitive
</Badge>
)}
{rate.csta_compliant && (
<Badge className="bg-blue-100 text-blue-700 text-[10px] h-auto px-2 py-0.5">
CSTA
</Badge>
)}
{rate.compliance_verified && (
<Badge className="bg-emerald-100 text-emerald-700 text-[10px] h-auto px-2 py-0.5">
Compliance
</Badge>
)}
</div>
</div>
<div className="flex-1 min-w-[300px]">
<div className="flex items-center gap-2 mb-2">
<div className="flex-1 bg-slate-200 rounded-full h-6 overflow-hidden flex text-xs font-semibold">
<div
className="bg-emerald-500 flex items-center justify-center text-white"
style={{ width: `${(baseWage / parseFloat(rate.client_rate || 1)) * 100}%` }}
title={`Employee Wage: $${baseWage.toFixed(2)}/hr`}
>
${baseWage.toFixed(2)}
</div>
<div
className="bg-blue-500 flex items-center justify-center text-white"
style={{ width: `${(markupAmount / parseFloat(rate.client_rate || 1)) * 100}%` }}
title={`Markup: ${rate.markup_percentage}% = $${markupAmount.toFixed(2)}/hr`}
>
{rate.markup_percentage}%
</div>
<div
className="bg-purple-500 flex items-center justify-center text-white"
style={{ width: `${(feeAmount / parseFloat(rate.client_rate || 1)) * 100}%` }}
title={`Vendor Fee: ${rate.vendor_fee_percentage}% = $${feeAmount.toFixed(2)}/hr`}
>
{rate.vendor_fee_percentage}%
</div>
</div>
</div>
<div className="flex items-center justify-between text-[10px] text-slate-600">
<span>Wage: ${baseWage.toFixed(2)}</span>
<span>Markup: {rate.markup_percentage}% (+${markupAmount.toFixed(2)})</span>
<span>VA Fee: {rate.vendor_fee_percentage}% (+${feeAmount.toFixed(2)})</span>
</div>
</div>
<div className="w-24 text-right">
<p className="text-2xl font-bold text-[#0A39DF]">${rate.client_rate}</p>
<p className="text-xs text-slate-500">/hourly</p>
</div>
<div className="w-32">
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${analysis.bg}`}>
<Icon className={`w-4 h-4 ${analysis.color}`} />
<div>
<p className={`text-xs font-semibold ${analysis.color}`}>{analysis.status.charAt(0).toUpperCase() + analysis.status.slice(1)}</p>
<p className="text-[10px] text-slate-600">{analysis.message}</p>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
)}
</Card>
);
})
)}
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,152 @@
import React from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Users, Star, Calendar, Plus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
export default function VendorStaff() {
const navigate = useNavigate();
const { data: user } = useQuery({
queryKey: ['current-user'],
queryFn: () => base44.auth.me(),
});
const { data: staff } = useQuery({
queryKey: ['vendor-staff'],
queryFn: () => base44.entities.Staff.list(),
initialData: [],
});
// Filter staff to only show THIS vendor's staff
const myStaff = staff.filter(s =>
s.vendor_id === user?.id ||
s.vendor_name === user?.company_name ||
s.created_by === user?.email
);
const stats = {
total: myStaff.length,
active: myStaff.filter(s => s.action !== 'Inactive').length,
topRated: myStaff.filter(s => s.rating >= 4.5).length,
avgRating: myStaff.length > 0
? (myStaff.reduce((sum, s) => sum + (s.rating || 0), 0) / myStaff.length).toFixed(1)
: "0.0",
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-[#1C323E]">My Staff</h1>
<p className="text-slate-500 mt-1">Manage your workforce</p>
</div>
<Button
onClick={() => navigate(createPageUrl("AddStaff"))}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
>
<Plus className="w-4 h-4 mr-2" />
Add Staff
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card className="border-slate-200">
<CardContent className="p-6">
<Users className="w-8 h-8 text-[#0A39DF] mb-2" />
<p className="text-sm text-slate-500">Total Staff</p>
<p className="text-3xl font-bold text-[#1C323E]">{stats.total}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<Calendar className="w-8 h-8 text-green-600 mb-2" />
<p className="text-sm text-slate-500">Active</p>
<p className="text-3xl font-bold text-green-600">{stats.active}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<Star className="w-8 h-8 text-amber-600 mb-2" />
<p className="text-sm text-slate-500">Top Rated</p>
<p className="text-3xl font-bold text-amber-600">{stats.topRated}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<Star className="w-8 h-8 text-blue-600 mb-2" />
<p className="text-sm text-slate-500">Avg Rating</p>
<p className="text-3xl font-bold text-blue-600">{stats.avgRating}</p>
</CardContent>
</Card>
</div>
{/* Staff Grid */}
{myStaff.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{myStaff.map((member) => (
<Card key={member.id} className="border-slate-200 hover:shadow-lg transition-shadow">
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4">
<div className="w-12 h-12 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-full flex items-center justify-center text-white font-bold text-lg">
{member.employee_name?.charAt(0) || '?'}
</div>
{member.rating && (
<Badge className="bg-amber-100 text-amber-700">
<Star className="w-3 h-3 mr-1" />
{member.rating}
</Badge>
)}
</div>
<h3 className="font-bold text-lg text-[#1C323E] mb-1">{member.employee_name}</h3>
<p className="text-sm text-slate-500 mb-3">{member.position || 'Staff Member'}</p>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-600">Shifts:</span>
<span className="font-semibold">{member.total_shifts || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Coverage:</span>
<span className="font-semibold text-green-600">{member.shift_coverage_percentage || 0}%</span>
</div>
</div>
<Button
onClick={() => navigate(createPageUrl("EditStaff") + `?id=${member.id}`)}
variant="outline"
className="w-full mt-4 hover:bg-[#0A39DF] hover:text-white"
>
View Profile
</Button>
</CardContent>
</Card>
))}
</div>
) : (
<Card className="border-slate-200">
<CardContent className="p-12 text-center">
<Users className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Staff Members Yet</h3>
<p className="text-slate-500 mb-6">Start building your team by adding staff members</p>
<Button
onClick={() => navigate(createPageUrl("AddStaff"))}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
>
<Plus className="w-4 h-4 mr-2" />
Add First Staff Member
</Button>
</CardContent>
</Card>
)}
</div>
</div>
);
}

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

View File

@@ -0,0 +1,169 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Shield, Search, CheckCircle2, AlertTriangle, XCircle, DollarSign, Award, FileText } from "lucide-react";
export default function WorkforceCompliance() {
const [searchTerm, setSearchTerm] = useState("");
const { data: staff = [] } = useQuery({
queryKey: ['staff-compliance'],
queryFn: () => base44.entities.Staff.list(),
initialData: [],
});
const { data: rates = [] } = useQuery({
queryKey: ['rates-compliance'],
queryFn: () => base44.entities.VendorRate.list(),
initialData: [],
});
// Calculate compliance metrics
const totalStaff = staff.length;
const backgroundCheckCompliant = staff.filter(s => s.background_check_status === "cleared").length;
const wageCompliant = rates.filter(r => r.employee_wage >= 16.50).length;
const certifiedStaff = staff.filter(s => s.certifications?.length > 0).length;
const complianceRate = totalStaff > 0 ? ((backgroundCheckCompliant / totalStaff) * 100).toFixed(1) : 0;
const wageComplianceRate = rates.length > 0 ? ((wageCompliant / rates.length) * 100).toFixed(1) : 0;
const filteredStaff = staff.filter(member =>
!searchTerm ||
member.employee_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
member.vendor_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold text-[#1C323E] mb-2">Workforce Compliance</h1>
<p className="text-slate-600">Monitor background checks, certifications, and fair wage compliance</p>
</div>
{/* Compliance Overview */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card className="border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<Shield className="w-8 h-8 text-green-600" />
<Badge className={complianceRate >= 95 ? "bg-green-100 text-green-700" : "bg-amber-100 text-amber-700"}>
{complianceRate}%
</Badge>
</div>
<p className="text-sm text-slate-500">Background Checks</p>
<p className="text-3xl font-bold text-[#1C323E]">{backgroundCheckCompliant}/{totalStaff}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<DollarSign className="w-8 h-8 text-blue-600" />
<Badge className={wageComplianceRate >= 100 ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700"}>
{wageComplianceRate}%
</Badge>
</div>
<p className="text-sm text-slate-500">Fair Wage Compliance</p>
<p className="text-3xl font-bold text-[#1C323E]">{wageCompliant}/{rates.length}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<Award className="w-8 h-8 text-purple-600" />
</div>
<p className="text-sm text-slate-500">Certified Staff</p>
<p className="text-3xl font-bold text-[#1C323E]">{certifiedStaff}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<FileText className="w-8 h-8 text-amber-600" />
</div>
<p className="text-sm text-slate-500">Active Workforce</p>
<p className="text-3xl font-bold text-[#1C323E]">{totalStaff}</p>
</CardContent>
</Card>
</div>
{/* Search */}
<Card className="mb-6 border-slate-200">
<CardContent className="p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
placeholder="Search by staff name or vendor..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</CardContent>
</Card>
{/* Staff Compliance List */}
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle>Workforce Compliance Status</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{filteredStaff.map((member) => {
const hasBackgroundCheck = member.background_check_status === "cleared";
const hasCertifications = member.certifications?.length > 0;
return (
<div key={member.id} className="p-4 border border-slate-200 rounded-lg hover:border-[#0A39DF] transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h4 className="font-semibold text-[#1C323E]">{member.employee_name}</h4>
<Badge variant="outline" className="text-xs">{member.vendor_name || "N/A"}</Badge>
</div>
<p className="text-sm text-slate-600 mb-3">{member.position || "Staff Member"}</p>
<div className="flex items-center gap-4 flex-wrap">
{hasBackgroundCheck ? (
<div className="flex items-center gap-1 text-green-700">
<CheckCircle2 className="w-4 h-4" />
<span className="text-sm font-medium">Background Check </span>
</div>
) : (
<div className="flex items-center gap-1 text-red-700">
<XCircle className="w-4 h-4" />
<span className="text-sm font-medium">No Background Check</span>
</div>
)}
{hasCertifications ? (
<div className="flex items-center gap-1 text-blue-700">
<Award className="w-4 h-4" />
<span className="text-sm font-medium">Certified</span>
</div>
) : (
<div className="flex items-center gap-1 text-slate-500">
<Award className="w-4 h-4" />
<span className="text-sm font-medium">No Certifications</span>
</div>
)}
</div>
</div>
<Badge className={hasBackgroundCheck && hasCertifications ? "bg-green-100 text-green-700" : "bg-amber-100 text-amber-700"}>
{hasBackgroundCheck && hasCertifications ? "Compliant" : "Review Required"}
</Badge>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,178 @@
import React from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Users, MapPin, DollarSign, Award, BookOpen, TrendingUp, Star, Clock, ArrowLeft, Calendar } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import PageHeader from "../components/common/PageHeader";
export default function WorkforceDashboard() {
const navigate = useNavigate();
const { data: staff } = useQuery({
queryKey: ['staff'],
queryFn: () => base44.entities.Staff.list('-rating'),
initialData: [],
});
const topPerformers = staff.slice(0, 6);
const avgRating = staff.length > 0 ? (staff.reduce((sum, s) => sum + (s.rating || 0), 0) / staff.length).toFixed(1) : 0;
const avgCoverage = staff.length > 0 ? Math.round(staff.reduce((sum, s) => sum + (s.shift_coverage_percentage || 0), 0) / staff.length) : 0;
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<PageHeader
title="Workforce App (Krower)"
subtitle="Job feed, check-ins, training, and earnings"
/>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<Users className="w-8 h-8 text-[#0A39DF]" />
<Badge className="bg-blue-100 text-blue-700">{staff.length}</Badge>
</div>
<p className="text-sm text-slate-500">Active Workers</p>
<p className="text-3xl font-bold text-[#1C323E]">{staff.length}</p>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<Star className="w-8 h-8 text-yellow-500" />
<Badge className="bg-yellow-100 text-yellow-700">{avgRating}/5</Badge>
</div>
<p className="text-sm text-slate-500">Avg Rating</p>
<p className="text-3xl font-bold text-[#1C323E]">{avgRating}</p>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<TrendingUp className="w-8 h-8 text-green-600" />
<Badge className="bg-green-100 text-green-700">{avgCoverage}%</Badge>
</div>
<p className="text-sm text-slate-500">Avg Coverage</p>
<p className="text-3xl font-bold text-[#1C323E]">{avgCoverage}%</p>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<BookOpen className="w-8 h-8 text-purple-600" />
<Badge className="bg-purple-100 text-purple-700">89%</Badge>
</div>
<p className="text-sm text-slate-500">Training Complete</p>
<p className="text-3xl font-bold text-[#1C323E]">89%</p>
</CardContent>
</Card>
</div>
{/* Top Performers */}
<Card className="mb-8 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Award className="w-5 h-5 text-amber-600" />
Top Performers
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{topPerformers.map((worker, index) => (
<div key={worker.id} className="p-6 rounded-xl border-2 border-slate-200 hover:border-[#0A39DF] hover:shadow-lg transition-all relative">
{index < 3 && (
<div className="absolute -top-3 -right-3 w-8 h-8 bg-gradient-to-br from-amber-400 to-amber-600 rounded-full flex items-center justify-center text-white font-bold text-sm shadow-lg">
{index + 1}
</div>
)}
<div className="flex items-center gap-3 mb-4">
<div className="w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center text-white font-bold text-xl">
{worker.initial || worker.employee_name?.charAt(0)}
</div>
<div className="flex-1">
<h4 className="font-bold text-[#1C323E]">{worker.employee_name}</h4>
<p className="text-sm text-slate-500">{worker.position}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<p className="text-xs text-slate-500">Rating</p>
<div className="flex items-center gap-1">
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
<span className="font-bold text-[#1C323E]">{(worker.rating || 0).toFixed(1)}</span>
</div>
</div>
<div>
<p className="text-xs text-slate-500">Coverage</p>
<span className="font-bold text-green-600">{worker.shift_coverage_percentage || 0}%</span>
</div>
</div>
<Badge className="w-full justify-center bg-blue-100 text-blue-700">
Tier: {(worker.rating || 0) >= 4.5 ? 'Gold' : (worker.rating || 0) >= 4.0 ? 'Silver' : 'Bronze'}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
{/* Feature Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card className="border-slate-200 shadow-lg hover:shadow-xl transition-all hover:-translate-y-1">
<CardContent className="p-6 text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center">
<Calendar className="w-8 h-8 text-white" />
</div>
<h3 className="font-bold text-[#1C323E] mb-2">Job Feed</h3>
<p className="text-sm text-slate-500 mb-4">Browse available shifts</p>
<Badge className="bg-green-100 text-green-700">24 Available</Badge>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg hover:shadow-xl transition-all hover:-translate-y-1">
<CardContent className="p-6 text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-emerald-500 to-emerald-700 rounded-xl flex items-center justify-center">
<MapPin className="w-8 h-8 text-white" />
</div>
<h3 className="font-bold text-[#1C323E] mb-2">Geo Check-In</h3>
<p className="text-sm text-slate-500 mb-4">Clock in/out at events</p>
<Badge className="bg-blue-100 text-blue-700">Location-based</Badge>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg hover:shadow-xl transition-all hover:-translate-y-1">
<CardContent className="p-6 text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-purple-500 to-purple-700 rounded-xl flex items-center justify-center">
<BookOpen className="w-8 h-8 text-white" />
</div>
<h3 className="font-bold text-[#1C323E] mb-2">KROW University</h3>
<p className="text-sm text-slate-500 mb-4">Training & certifications</p>
<Badge className="bg-purple-100 text-purple-700">89% Complete</Badge>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg hover:shadow-xl transition-all hover:-translate-y-1">
<CardContent className="p-6 text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-amber-500 to-amber-700 rounded-xl flex items-center justify-center">
<DollarSign className="w-8 h-8 text-white" />
</div>
<h3 className="font-bold text-[#1C323E] mb-2">Earnings</h3>
<p className="text-sm text-slate-500 mb-4">Track your income</p>
<Badge className="bg-green-100 text-green-700">$4,280 MTD</Badge>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { DollarSign, TrendingUp, Calendar, Award } from "lucide-react";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
export default function WorkforceEarnings() {
const earningsData = [
{ month: 'Jan', earnings: 2400, hours: 96 },
{ month: 'Feb', earnings: 2800, hours: 112 },
{ month: 'Mar', earnings: 3200, hours: 128 },
{ month: 'Apr', earnings: 2600, hours: 104 },
{ month: 'May', earnings: 3400, hours: 136 },
];
const stats = {
totalEarnings: 14400,
thisMonth: 3400,
totalHours: 576,
avgHourly: 25,
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold text-[#1C323E]">My Earnings</h1>
<p className="text-slate-500 mt-1">Track your income and hours worked</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card className="border-slate-200">
<CardContent className="p-6">
<DollarSign className="w-8 h-8 text-[#0A39DF] mb-2" />
<p className="text-sm text-slate-500">Total Earnings (YTD)</p>
<p className="text-3xl font-bold text-[#1C323E]">${stats.totalEarnings.toLocaleString()}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<TrendingUp className="w-8 h-8 text-green-600 mb-2" />
<p className="text-sm text-slate-500">This Month</p>
<p className="text-3xl font-bold text-green-600">${stats.thisMonth.toLocaleString()}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<Calendar className="w-8 h-8 text-blue-600 mb-2" />
<p className="text-sm text-slate-500">Total Hours</p>
<p className="text-3xl font-bold text-blue-600">{stats.totalHours}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<Award className="w-8 h-8 text-purple-600 mb-2" />
<p className="text-sm text-slate-500">Avg Hourly</p>
<p className="text-3xl font-bold text-purple-600">${stats.avgHourly}</p>
</CardContent>
</Card>
</div>
{/* Earnings Chart */}
<Card className="mb-8 border-slate-200">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle>Monthly Earnings</CardTitle>
</CardHeader>
<CardContent className="p-6">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={earningsData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="earnings" fill="#0A39DF" name="Earnings ($)" />
<Bar dataKey="hours" fill="#10b981" name="Hours Worked" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Payment History */}
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle>Recent Payments</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{[
{ date: "May 15, 2025", event: "Tech Conference 2025", hours: 32, amount: 800 },
{ date: "May 8, 2025", event: "Product Launch", hours: 24, amount: 600 },
{ date: "May 1, 2025", event: "Corporate Event", hours: 40, amount: 1000 },
].map((payment, index) => (
<div key={index} className="flex items-center justify-between p-4 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex-1">
<h4 className="font-semibold text-[#1C323E]">{payment.event}</h4>
<p className="text-sm text-slate-500 mt-1">{payment.date} {payment.hours} hours</p>
</div>
<p className="text-2xl font-bold text-[#0A39DF]">${payment.amount}</p>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,255 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { User, Mail, Phone, MapPin, Award, Star, Edit, Camera, Upload } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
export default function WorkforceProfile() {
const [showUploadDialog, setShowUploadDialog] = useState(false);
const [uploading, setUploading] = useState(false);
const queryClient = useQueryClient();
const { toast } = useToast();
const { data: user } = useQuery({
queryKey: ['current-user'],
queryFn: () => base44.auth.me(),
});
// Sample avatar if user doesn't have one
const sampleAvatar = "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=400&fit=crop";
const userAvatar = user?.profile_picture || sampleAvatar;
const updateProfilePictureMutation = useMutation({
mutationFn: (profilePictureUrl) => base44.auth.updateMe({ profile_picture: profilePictureUrl }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['current-user'] });
toast({
title: "Profile Picture Updated",
description: "Your profile picture has been updated successfully",
});
setShowUploadDialog(false);
},
});
const handleFileUpload = async (event) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
toast({
title: "Invalid File",
description: "Please upload an image file",
variant: "destructive",
});
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
toast({
title: "File Too Large",
description: "Please upload an image smaller than 5MB",
variant: "destructive",
});
return;
}
setUploading(true);
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
await updateProfilePictureMutation.mutateAsync(file_url);
} catch (error) {
toast({
title: "Upload Failed",
description: error.message || "Failed to upload profile picture",
variant: "destructive",
});
} finally {
setUploading(false);
}
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-4xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold text-[#1C323E]">My Profile</h1>
<p className="text-slate-500 mt-1">Manage your personal information and certifications</p>
</div>
{/* Profile Card */}
<Card className="mb-8 border-slate-200">
<CardContent className="p-8">
<div className="flex items-start justify-between mb-6">
<div className="flex items-center gap-6">
<div className="relative">
<Avatar className="w-24 h-24 border-4 border-white shadow-lg">
<AvatarImage src={userAvatar} alt={user?.full_name} />
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white text-3xl font-bold">
{user?.full_name?.charAt(0) || user?.email?.charAt(0) || '?'}
</AvatarFallback>
</Avatar>
<button
onClick={() => setShowUploadDialog(true)}
className="absolute bottom-0 right-0 w-8 h-8 bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white rounded-full flex items-center justify-center shadow-lg transition-all"
title="Change profile picture"
>
<Camera className="w-4 h-4" />
</button>
</div>
<div>
<h2 className="text-2xl font-bold text-[#1C323E] mb-2">{user?.full_name || 'User'}</h2>
<Badge className="bg-blue-100 text-blue-700 mb-2">Workforce</Badge>
<div className="flex items-center gap-2 text-sm text-slate-600">
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
<span className="font-semibold">4.8 Rating</span>
<span className="text-slate-400"></span>
<span>127 completed shifts</span>
</div>
</div>
</div>
<Button variant="outline">
<Edit className="w-4 h-4 mr-2" />
Edit Profile
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="flex items-center gap-3 p-4 bg-slate-50 rounded-lg">
<Mail className="w-5 h-5 text-[#0A39DF]" />
<div>
<p className="text-xs text-slate-500">Email</p>
<p className="font-medium text-slate-700">{user?.email}</p>
</div>
</div>
<div className="flex items-center gap-3 p-4 bg-slate-50 rounded-lg">
<Phone className="w-5 h-5 text-[#0A39DF]" />
<div>
<p className="text-xs text-slate-500">Phone</p>
<p className="font-medium text-slate-700">{user?.phone || 'Not provided'}</p>
</div>
</div>
<div className="flex items-center gap-3 p-4 bg-slate-50 rounded-lg md:col-span-2">
<MapPin className="w-5 h-5 text-[#0A39DF]" />
<div>
<p className="text-xs text-slate-500">Location</p>
<p className="font-medium text-slate-700">San Francisco, CA</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Certifications */}
<Card className="mb-8 border-slate-200">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle className="flex items-center gap-2">
<Award className="w-5 h-5 text-[#0A39DF]" />
Certifications & Training
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{[
{ name: "Food Handler Certificate", status: "Valid", expiry: "Dec 2025" },
{ name: "ServSafe Alcohol", status: "Valid", expiry: "Mar 2026" },
{ name: "First Aid & CPR", status: "Valid", expiry: "Aug 2025" },
].map((cert, index) => (
<div key={index} className="flex items-center justify-between p-4 bg-slate-50 rounded-lg">
<div>
<h4 className="font-semibold text-[#1C323E]">{cert.name}</h4>
<p className="text-sm text-slate-500 mt-1">Expires: {cert.expiry}</p>
</div>
<Badge className="bg-green-100 text-green-700">{cert.status}</Badge>
</div>
))}
</div>
</CardContent>
</Card>
{/* Performance Stats */}
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle>Performance Summary</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4">
<p className="text-3xl font-bold text-[#0A39DF]">97%</p>
<p className="text-sm text-slate-500 mt-1">Attendance Rate</p>
</div>
<div className="text-center p-4">
<p className="text-3xl font-bold text-green-600">4.8</p>
<p className="text-sm text-slate-500 mt-1">Avg Rating</p>
</div>
<div className="text-center p-4">
<p className="text-3xl font-bold text-blue-600">127</p>
<p className="text-sm text-slate-500 mt-1">Total Shifts</p>
</div>
<div className="text-center p-4">
<p className="text-3xl font-bold text-purple-600">0</p>
<p className="text-sm text-slate-500 mt-1">Cancellations</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Upload Profile Picture Dialog */}
<Dialog open={showUploadDialog} onOpenChange={setShowUploadDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Change Profile Picture</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-col items-center gap-4 p-6 border-2 border-dashed border-slate-300 rounded-lg">
<Avatar className="w-32 h-32">
<AvatarImage src={userAvatar} />
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white text-4xl font-bold">
{user?.full_name?.charAt(0) || user?.email?.charAt(0) || '?'}
</AvatarFallback>
</Avatar>
<div className="text-center">
<label htmlFor="file-upload" className="cursor-pointer">
<div className="flex items-center gap-2 px-4 py-2 bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white rounded-lg transition-colors">
<Upload className="w-4 h-4" />
<span>{uploading ? 'Uploading...' : 'Choose Image'}</span>
</div>
<input
id="file-upload"
type="file"
accept="image/*"
className="hidden"
onChange={handleFileUpload}
disabled={uploading}
/>
</label>
<p className="text-xs text-slate-500 mt-2">PNG, JPG or GIF Max 5MB</p>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowUploadDialog(false)} disabled={uploading}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import React from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Calendar, MapPin, Clock, DollarSign, CheckCircle2, AlertCircle } from "lucide-react";
import { format } from "date-fns";
// Safe date formatter
const safeFormatDate = (dateString, formatStr) => {
if (!dateString) return "Date TBD";
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return "Date TBD";
return format(date, formatStr);
} catch {
return "Date TBD";
}
};
export default function WorkforceShifts() {
const { data: user } = useQuery({
queryKey: ['current-user'],
queryFn: () => base44.auth.me(),
});
const { data: events } = useQuery({
queryKey: ['workforce-events'],
queryFn: () => base44.entities.Event.list('-date'),
initialData: [],
});
// Filter events where this user is assigned
const myShifts = events.filter(event =>
event.assigned_staff?.some(staff => staff.staff_id === user?.id)
);
const upcoming = myShifts.filter(e => new Date(e.date) >= new Date()).length;
const confirmed = myShifts.filter(e =>
e.assigned_staff?.find(s => s.staff_id === user?.id)?.confirmed
).length;
const pending = myShifts.filter(e =>
!e.assigned_staff?.find(s => s.staff_id === user?.id)?.confirmed
).length;
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold text-[#1C323E]">My Shifts</h1>
<p className="text-slate-500 mt-1">View and manage your upcoming shifts</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<Card className="border-slate-200">
<CardContent className="p-6">
<Calendar className="w-8 h-8 text-[#0A39DF] mb-2" />
<p className="text-sm text-slate-500">Upcoming Shifts</p>
<p className="text-3xl font-bold text-[#1C323E]">{upcoming}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<CheckCircle2 className="w-8 h-8 text-green-600 mb-2" />
<p className="text-sm text-slate-500">Confirmed</p>
<p className="text-3xl font-bold text-green-600">{confirmed}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<AlertCircle className="w-8 h-8 text-yellow-600 mb-2" />
<p className="text-sm text-slate-500">Pending Confirmation</p>
<p className="text-3xl font-bold text-yellow-600">{pending}</p>
</CardContent>
</Card>
</div>
{/* Shifts List */}
<div className="space-y-6">
{myShifts.map((shift) => {
const staffInfo = shift.assigned_staff?.find(s => s.staff_id === user?.id);
return (
<Card key={shift.id} className="border-slate-200">
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<h3 className="text-xl font-bold text-[#1C323E]">{shift.event_name}</h3>
<Badge className={staffInfo?.confirmed ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700"}>
{staffInfo?.confirmed ? "Confirmed" : "Pending"}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm text-slate-600">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
{safeFormatDate(shift.date, 'PPP')}
</div>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
{shift.event_location || 'Location TBD'}
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
8:00 AM - 5:00 PM
</div>
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4" />
<span className="font-semibold">$25/hour</span>
</div>
</div>
</div>
{!staffInfo?.confirmed && (
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
Confirm Shift
</Button>
)}
</div>
{shift.notes && (
<div className="mt-3 p-3 bg-slate-50 rounded-lg">
<p className="text-sm font-semibold text-slate-700 mb-1">Event Details:</p>
<p className="text-sm text-slate-600">{shift.notes}</p>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,267 @@
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
// Auth components
import ProtectedRoute from '@/components/auth/ProtectedRoute';
import PublicRoute from '@/components/auth/PublicRoute';
// Layout and pages
import Layout from "./Layout.jsx";
import Home from "./Home";
import Login from "./Login";
import Register from "./Register";
import Dashboard from "./Dashboard";
import StaffDirectory from "./StaffDirectory";
import AddStaff from "./AddStaff";
import EditStaff from "./EditStaff";
import Events from "./Events";
import CreateEvent from "./CreateEvent";
import EditEvent from "./EditEvent";
import EventDetail from "./EventDetail";
import Business from "./Business";
import Invoices from "./Invoices";
import Payroll from "./Payroll";
import Certification from "./Certification";
import Support from "./Support";
import Reports from "./Reports";
import Settings from "./Settings";
import ActivityLog from "./ActivityLog";
import AddBusiness from "./AddBusiness";
import EditBusiness from "./EditBusiness";
import ProcurementDashboard from "./ProcurementDashboard";
import OperatorDashboard from "./OperatorDashboard";
import VendorDashboard from "./VendorDashboard";
import WorkforceDashboard from "./WorkforceDashboard";
import Messages from "./Messages";
import ClientDashboard from "./ClientDashboard";
import Onboarding from "./Onboarding";
import ClientOrders from "./ClientOrders";
import ClientInvoices from "./ClientInvoices";
import VendorOrders from "./VendorOrders";
import VendorStaff from "./VendorStaff";
import VendorInvoices from "./VendorInvoices";
import VendorPerformance from "./VendorPerformance";
import WorkforceShifts from "./WorkforceShifts";
import WorkforceEarnings from "./WorkforceEarnings";
import WorkforceProfile from "./WorkforceProfile";
import UserManagement from "./UserManagement";
import VendorRateCard from "./VendorRateCard";
import Permissions from "./Permissions";
import WorkforceCompliance from "./WorkforceCompliance";
import Teams from "./Teams";
import CreateTeam from "./CreateTeam";
import TeamDetails from "./TeamDetails";
import VendorManagement from "./VendorManagement";
import PartnerManagement from "./PartnerManagement";
import EnterpriseManagement from "./EnterpriseManagement";
import VendorOnboarding from "./VendorOnboarding";
import SectorManagement from "./SectorManagement";
import AddEnterprise from "./AddEnterprise";
import AddSector from "./AddSector";
import AddPartner from "./AddPartner";
import EditVendor from "./EditVendor";
import SmartVendorOnboarding from "./SmartVendorOnboarding";
import InviteVendor from "./InviteVendor";
import VendorCompliance from "./VendorCompliance";
import EditPartner from "./EditPartner";
import EditSector from "./EditSector";
import EditEnterprise from "./EditEnterprise";
import VendorRates from "./VendorRates";
import VendorDocumentReview from "./VendorDocumentReview";
import VendorMarketplace from "./VendorMarketplace";
import RapidOrder from "./RapidOrder";
import SmartScheduler from "./SmartScheduler";
import StaffOnboarding from "./StaffOnboarding";
import NotificationSettings from "./NotificationSettings";
import TaskBoard from "./TaskBoard";
import InvoiceDetail from "./InvoiceDetail";
import InvoiceEditor from "./InvoiceEditor";
import Tutorials from "./Tutorials";
import Schedule from "./Schedule";
import StaffAvailability from "./StaffAvailability";
import WorkerShiftProposals from "./WorkerShiftProposals";
const PAGES = {
Dashboard,
StaffDirectory,
AddStaff,
EditStaff,
Events,
CreateEvent,
EditEvent,
EventDetail,
Business,
Invoices,
Payroll,
Certification,
Support,
Reports,
Settings,
ActivityLog,
AddBusiness,
EditBusiness,
ProcurementDashboard,
OperatorDashboard,
VendorDashboard,
WorkforceDashboard,
Messages,
ClientDashboard,
Onboarding,
ClientOrders,
ClientInvoices,
VendorOrders,
VendorStaff,
VendorInvoices,
VendorPerformance,
WorkforceShifts,
WorkforceEarnings,
WorkforceProfile,
UserManagement,
Home,
VendorRateCard,
Permissions,
WorkforceCompliance,
Teams,
CreateTeam,
TeamDetails,
VendorManagement,
PartnerManagement,
EnterpriseManagement,
VendorOnboarding,
SectorManagement,
AddEnterprise,
AddSector,
AddPartner,
EditVendor,
SmartVendorOnboarding,
InviteVendor,
VendorCompliance,
EditPartner,
EditSector,
EditEnterprise,
VendorRates,
VendorDocumentReview,
VendorMarketplace,
RapidOrder,
SmartScheduler,
StaffOnboarding,
NotificationSettings,
TaskBoard,
InvoiceDetail,
InvoiceEditor,
Tutorials,
Schedule,
StaffAvailability,
WorkerShiftProposals,
};
function _getCurrentPage(url) {
if (url.endsWith('/')) url = url.slice(0, -1);
let last = url.split('/').pop();
if (last.includes('?')) last = last.split('?')[0];
const pageName = Object.keys(PAGES).find(p => p.toLowerCase() === last.toLowerCase());
return pageName || 'Home'; // Default to Home
}
function AppRoutes() {
const location = useLocation();
const currentPage = _getCurrentPage(location.pathname);
return (
<Routes>
{/* Public Routes */}
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
<Route path="/register" element={<PublicRoute><Register /></PublicRoute>} />
{/* Private Routes */}
<Route path="/*" element={
<ProtectedRoute>
<Layout currentPageName={currentPage}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/Dashboard" element={<Dashboard />} />
<Route path="/StaffDirectory" element={<StaffDirectory />} />
<Route path="/AddStaff" element={<AddStaff />} />
<Route path="/EditStaff" element={<EditStaff />} />
<Route path="/Events" element={<Events />} />
<Route path="/CreateEvent" element={<CreateEvent />} />
<Route path="/EditEvent" element={<EditEvent />} />
<Route path="/EventDetail" element={<EventDetail />} />
<Route path="/Business" element={<Business />} />
<Route path="/Invoices" element={<Invoices />} />
<Route path="/Payroll" element={<Payroll />} />
<Route path="/Certification" element={<Certification />} />
<Route path="/Support" element={<Support />} />
<Route path="/Reports" element={<Reports />} />
<Route path="/Settings" element={<Settings />} />
<Route path="/ActivityLog" element={<ActivityLog />} />
<Route path="/AddBusiness" element={<AddBusiness />} />
<Route path="/EditBusiness" element={<EditBusiness />} />
<Route path="/ProcurementDashboard" element={<ProcurementDashboard />} />
<Route path="/OperatorDashboard" element={<OperatorDashboard />} />
<Route path="/VendorDashboard" element={<VendorDashboard />} />
<Route path="/WorkforceDashboard" element={<WorkforceDashboard />} />
<Route path="/Messages" element={<Messages />} />
<Route path="/ClientDashboard" element={<ClientDashboard />} />
<Route path="/Onboarding" element={<Onboarding />} />
<Route path="/ClientOrders" element={<ClientOrders />} />
<Route path="/ClientInvoices" element={<ClientInvoices />} />
<Route path="/VendorOrders" element={<VendorOrders />} />
<Route path="/VendorStaff" element={<VendorStaff />} />
<Route path="/VendorInvoices" element={<VendorInvoices />} />
<Route path="/VendorPerformance" element={<VendorPerformance />} />
<Route path="/WorkforceShifts" element={<WorkforceShifts />} />
<Route path="/WorkforceEarnings" element={<WorkforceEarnings />} />
<Route path="/WorkforceProfile" element={<WorkforceProfile />} />
<Route path="/UserManagement" element={<UserManagement />} />
<Route path="/Home" element={<Home />} />
<Route path="/VendorRateCard" element={<VendorRateCard />} />
<Route path="/Permissions" element={<Permissions />} />
<Route path="/WorkforceCompliance" element={<WorkforceCompliance />} />
<Route path="/Teams" element={<Teams />} />
<Route path="/CreateTeam" element={<CreateTeam />} />
<Route path="/TeamDetails" element={<TeamDetails />} />
<Route path="/VendorManagement" element={<VendorManagement />} />
<Route path="/PartnerManagement" element={<PartnerManagement />} />
<Route path="/EnterpriseManagement" element={<EnterpriseManagement />} />
<Route path="/VendorOnboarding" element={<VendorOnboarding />} />
<Route path="/SectorManagement" element={<SectorManagement />} />
<Route path="/AddEnterprise" element={<AddEnterprise />} />
<Route path="/AddSector" element={<AddSector />} />
<Route path="/AddPartner" element={<AddPartner />} />
<Route path="/EditVendor" element={<EditVendor />} />
<Route path="/SmartVendorOnboarding" element={<SmartVendorOnboarding />} />
<Route path="/InviteVendor" element={<InviteVendor />} />
<Route path="/VendorCompliance" element={<VendorCompliance />} />
<Route path="/EditPartner" element={<EditPartner />} />
<Route path="/EditSector" element={<EditSector />} />
<Route path="/EditEnterprise" element={<EditEnterprise />} />
<Route path="/VendorRates" element={<VendorRates />} />
<Route path="/VendorDocumentReview" element={<VendorDocumentReview />} />
<Route path="/VendorMarketplace" element={<VendorMarketplace />} />
<Route path="/RapidOrder" element={<RapidOrder />} />
<Route path="/SmartScheduler" element={<SmartScheduler />} />
<Route path="/StaffOnboarding" element={<StaffOnboarding />} />
<Route path="/NotificationSettings" element={<NotificationSettings />} />
<Route path="/TaskBoard" element={<TaskBoard />} />
<Route path="/InvoiceDetail" element={<InvoiceDetail />} />
<Route path="/InvoiceEditor" element={<InvoiceEditor />} />
<Route path="/Tutorials" element={<Tutorials />} />
<Route path="/Schedule" element={<Schedule />} />
<Route path="/StaffAvailability" element={<StaffAvailability />} />
<Route path="/WorkerShiftProposals" element={<WorkerShiftProposals />} />
</Routes>
</Layout>
</ProtectedRoute>
} />
</Routes>
);
}
export default function Pages() {
return (
<Router>
<AppRoutes />
</Router>
);
}