feat: Initial commit of KROW Workforce Web client (Base44 export)

This commit is contained in:
bwnyasse
2025-11-11 06:08:01 -05:00
commit e571193362
173 changed files with 50898 additions and 0 deletions

154
src/pages/ActivityLog.jsx Normal file
View File

@@ -0,0 +1,154 @@
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",
};
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({ user_id: 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">
{formatDistanceToNow(new Date(activity.created_date), { addSuffix: true })}
</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>
);
}

170
src/pages/AddBusiness.jsx Normal file
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>
);
}

271
src/pages/AddEnterprise.jsx Normal file
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>
);
}

319
src/pages/AddPartner.jsx Normal file
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>
);
}

196
src/pages/AddSector.jsx Normal file
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>
);
}

49
src/pages/AddStaff.jsx Normal file
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>
);
}

343
src/pages/Business.jsx Normal file
View File

@@ -0,0 +1,343 @@
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 } from "lucide-react";
import { Link, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import PageHeader from "../components/common/PageHeader";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import CreateBusinessModal from "../components/business/CreateBusinessModal";
export default function Business() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("active");
const [expandedCompanies, setExpandedCompanies] = useState({});
const [createModalOpen, setCreateModalOpen] = useState(false);
const { 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 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 handleCreateBusiness = (businessData) => {
createBusinessMutation.mutate(businessData);
};
// 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]);
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())
);
return matchesSearch;
});
const toggleCompany = (companyName) => {
setExpandedCompanies(prev => ({
...prev,
[companyName]: !prev[companyName]
}));
};
const canAddBusiness = ["admin", "procurement", "operator", "vendor"].includes(userRole);
const totalHubs = filteredBusinesses.reduce((sum, company) => sum + company.hubs.length, 0);
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={`${filteredBusinesses.length} ${filteredBusinesses.length === 1 ? 'company' : 'companies'}${totalHubs} total hubs`}
actions={
canAddBusiness ? (
<Button
onClick={() => setCreateModalOpen(true)}
className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg"
>
<Plus className="w-5 h-5 mr-2" />
Add Business
</Button>
) : null
}
/>
{/* Status Tabs */}
<Tabs value={statusFilter} onValueChange={setStatusFilter} className="mb-6">
<TabsList className="bg-white border border-slate-200">
<TabsTrigger value="active" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white">
Active
<Badge variant="secondary" className="ml-2 bg-slate-100 text-slate-700">
{filteredBusinesses.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="pending">
Pending
<Badge variant="secondary" className="ml-2 bg-slate-100 text-slate-700">
0
</Badge>
</TabsTrigger>
<TabsTrigger value="deactivated">
Deactivated
<Badge variant="secondary" className="ml-2 bg-slate-100 text-slate-700">
0
</Badge>
</TabsTrigger>
</TabsList>
</Tabs>
{/* Search Bar */}
<div className="mb-6">
<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 companies, hubs, or contacts..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-white border-slate-200"
/>
</div>
</div>
{/* Consolidated Business List */}
{filteredBusinesses.length > 0 ? (
<div className="space-y-4">
{filteredBusinesses.map((company) => (
<Card key={company.company_name} className="border-slate-200 shadow-sm overflow-hidden">
<Collapsible
open={expandedCompanies[company.company_name]}
onOpenChange={() => toggleCompany(company.company_name)}
>
<div className="bg-gradient-to-r from-slate-50 to-white border-b border-slate-200">
<CollapsibleTrigger className="w-full">
<div className="p-6 flex items-center justify-between hover:bg-slate-50/50 transition-colors">
<div className="flex items-center gap-4">
{expandedCompanies[company.company_name] ? (
<ChevronDown className="w-5 h-5 text-slate-400" />
) : (
<ChevronRight className="w-5 h-5 text-slate-400" />
)}
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center text-white font-bold text-xl">
{company.company_name?.charAt(0) || 'B'}
</div>
<div className="text-left">
<h3 className="text-xl font-bold text-[#1C323E]">{company.company_name}</h3>
<div className="flex items-center gap-4 mt-1 text-sm text-slate-500">
<span className="flex items-center gap-1">
<Building2 className="w-4 h-4" />
{company.hubs.length} {company.hubs.length === 1 ? 'hub' : 'hubs'}
</span>
{company.sector && (
<Badge variant="outline" className="text-xs">
{company.sector}
</Badge>
)}
</div>
</div>
</div>
<div className="text-right">
<div className="text-sm font-medium text-slate-700">
{company.primary_contact || '—'}
</div>
{company.primary_email && (
<div className="text-xs text-slate-500 flex items-center justify-end gap-1 mt-1">
<Mail className="w-3 h-3" />
{company.primary_email}
</div>
)}
{company.primary_phone && (
<div className="text-xs text-slate-500 flex items-center justify-end gap-1 mt-1">
<Phone className="w-3 h-3" />
{company.primary_phone}
</div>
)}
</div>
</div>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className="bg-white">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600">Hub Name</th>
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600">Contact</th>
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600">Address</th>
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600">City</th>
<th className="text-center py-3 px-6 font-semibold text-xs text-slate-600">Actions</th>
</tr>
</thead>
<tbody>
{company.hubs.map((hub, idx) => (
<tr
key={hub.id}
className={`border-b border-slate-100 hover:bg-slate-50 transition-colors ${
idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/30'
}`}
>
<td className="py-4 px-6">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-blue-500" />
<span className="font-medium text-sm text-slate-900">{hub.hub_name}</span>
</div>
</td>
<td className="py-4 px-6">
<div className="text-sm">
<p className="font-medium text-slate-900">{hub.contact_name || '—'}</p>
{hub.email && (
<p className="text-xs text-slate-500 flex items-center gap-1 mt-0.5">
<Mail className="w-3 h-3" />
{hub.email}
</p>
)}
{hub.phone && (
<p className="text-xs text-slate-500 flex items-center gap-1 mt-0.5">
<Phone className="w-3 h-3" />
{hub.phone}
</p>
)}
</div>
</td>
<td className="py-4 px-6">
<p className="text-sm text-slate-600">{hub.address || '—'}</p>
</td>
<td className="py-4 px-6">
<p className="text-sm text-slate-600">{hub.city || '—'}</p>
</td>
<td className="py-4 px-6">
<div className="flex items-center justify-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EditBusiness?id=${hub.id}`))}
className="text-slate-400 hover:text-blue-600 hover:bg-blue-50 h-8 w-8"
title="View/Edit Hub"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-slate-400 hover:text-red-600 hover:bg-red-50 h-8 w-8"
title="Delete Hub"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleContent>
</Collapsible>
</Card>
))}
</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>
);
}

View File

@@ -0,0 +1,424 @@
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, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Calendar, Plus, Clock, CheckCircle, DollarSign, FileText, MessageSquare, RefreshCw, Zap, TrendingUp, Star, ArrowRight, Users, CloudOff, MapPin } from "lucide-react";
import { format, parseISO } from "date-fns";
import QuickReorderModal from "../components/events/QuickReorderModal";
export default function ClientDashboard() {
const [reorderModalOpen, setReorderModalOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState(null);
const { data: user } = useQuery({
queryKey: ['current-user'],
queryFn: () => base44.auth.me(),
});
const { data: events } = useQuery({
queryKey: ['client-events'],
queryFn: async () => {
const allEvents = await base44.entities.Event.list('-date');
const clientEvents = allEvents.filter(e =>
e.client_email === user?.email ||
e.business_name === user?.company_name ||
e.created_by === user?.email
);
if (clientEvents.length === 0) {
return allEvents.filter(e => e.status === "Completed");
}
return clientEvents;
},
initialData: [],
enabled: !!user
});
const pendingOrders = events.filter(e => e.status === "Pending" || e.status === "Draft").length;
const activeOrders = events.filter(e => e.status === "Active" || e.status === "Confirmed").length;
const completedOrders = events.filter(e => e.status === "Completed").length;
const upcomingEvents = events
.filter(e => new Date(e.date) > new Date() && e.status !== "Canceled")
.slice(0, 5);
const pastOrders = events.filter(e => e.status === "Completed");
const orderFrequency = pastOrders.reduce((acc, event) => {
const key = event.event_name;
if (!acc[key]) {
acc[key] = { event, count: 0, lastOrdered: event.date };
}
acc[key].count++;
if (new Date(event.date) > new Date(acc[key].lastOrdered)) {
acc[key].lastOrdered = event.date;
acc[key].event = event;
}
return acc;
}, {});
const frequentOrders = Object.values(orderFrequency)
.sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return new Date(b.lastOrdered) - new Date(a.lastOrdered);
})
.slice(0, 3);
const handleQuickReorder = (event) => {
setSelectedEvent(event);
setReorderModalOpen(true);
};
const getMostLikedRank = (index) => {
if (index === 0) return { text: "#1 Most Ordered", color: "bg-gradient-to-r from-amber-500 to-orange-500", icon: "🏆" };
if (index === 1) return { text: "#2 Most Popular", color: "bg-gradient-to-r from-blue-500 to-indigo-500", icon: "⭐" };
if (index === 2) return { text: "#3 Top Choice", color: "bg-gradient-to-r from-purple-500 to-pink-500", icon: "💎" };
return null;
};
// Calculate time savings (assuming each manual order takes 15 minutes)
const totalReorders = frequentOrders.reduce((sum, item) => sum + item.count, 0);
const timeSavedMinutes = totalReorders * 15; // 15 minutes per order
const timeSavedHours = Math.floor(timeSavedMinutes / 60);
const remainingMinutes = timeSavedMinutes % 60;
const handleRefresh = () => {
window.location.reload();
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-slate-50">
<div className="max-w-[1600px] mx-auto p-4 md:p-8">
{/* Unpublished Changes */}
<button
onClick={handleRefresh}
className="flex items-center gap-2 mb-4 text-slate-500 hover:text-[#0A39DF] hover:bg-blue-50 px-3 py-2 rounded-lg transition-all group"
>
<CloudOff className="w-5 h-5 group-hover:animate-pulse" />
<span className="text-sm font-medium">Unpublished changes</span>
<RefreshCw className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
{/* Main Header */}
<div className="mb-8">
<h1 className="text-3xl md:text-4xl font-bold bg-gradient-to-r from-[#1C323E] to-[#0A39DF] bg-clip-text text-transparent mb-2">
Welcome back, {user?.full_name || user?.company_name || 'User'}
</h1>
<p className="text-lg text-slate-600">Streamline your workforce management</p>
</div>
{/* ORDER IT AGAIN - DOORDASH STYLE */}
{frequentOrders.length > 0 && (
<div className="mb-12">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-[#1C323E] mb-1">Order it again</h2>
<p className="text-slate-600">Your go-to orders Click to reorder instantly</p>
</div>
<div className="bg-gradient-to-r from-green-50 to-emerald-50 px-6 py-3 rounded-xl border border-green-200">
<p className="text-xs text-green-700 font-semibold uppercase mb-1"> Time Saved</p>
<p className="text-2xl font-bold text-green-600">
{timeSavedHours > 0 ? `${timeSavedHours}h ${remainingMinutes}m` : `${timeSavedMinutes}m`}
</p>
</div>
</div>
{/* Scrollable Cards */}
<div className="relative">
{/* Fade Edges */}
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-slate-50 via-blue-50/30 to-transparent z-10 pointer-events-none"></div>
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-slate-50 via-blue-50/30 to-transparent z-10 pointer-events-none"></div>
<div className="flex gap-4 overflow-x-auto pb-4 snap-x snap-mandatory scrollbar-hide px-1">
{frequentOrders.map((item, index) => {
const { event, count, lastOrdered } = item;
const rank = getMostLikedRank(index);
return (
<div
key={event.id}
className="flex-shrink-0 w-[320px] snap-start group cursor-pointer"
onClick={() => handleQuickReorder(event)}
>
<div className="relative bg-white rounded-2xl border-2 border-slate-200 hover:border-[#0A39DF] hover:shadow-2xl hover:scale-105 transition-all duration-300 overflow-hidden">
{/* Rank Badge */}
{rank && (
<div className="absolute top-3 left-3 z-10">
<div className={`${rank.color} text-white text-xs font-bold px-3 py-1.5 rounded-full shadow-lg flex items-center gap-1.5`}>
<span>{rank.icon}</span>
<span>#{index + 1}</span>
</div>
</div>
)}
{/* Reorder Button - Top Right + Icon */}
<button
onClick={(e) => {
e.stopPropagation();
handleQuickReorder(event);
}}
className="absolute top-3 right-3 w-10 h-10 bg-white rounded-full shadow-lg border-2 border-slate-200 flex items-center justify-center hover:bg-[#0A39DF] hover:border-[#0A39DF] hover:scale-110 transition-all z-10 group/btn"
>
<Plus className="w-5 h-5 text-slate-700 group-hover/btn:text-white transition-colors" />
</button>
{/* Hover Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-[#0A39DF]/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-[1]" />
{/* Card Content */}
<div className="p-5 pt-14 relative z-[2]">
{/* Event Name */}
<h3 className="font-bold text-lg text-[#1C323E] mb-3 line-clamp-2 leading-tight group-hover:text-[#0A39DF] transition-colors">
{event.event_name}
</h3>
{/* Manager & Hub Info */}
<div className="space-y-2 mb-3">
{event.manager_name && (
<div className="flex items-center gap-2 text-sm text-slate-600">
<div className="w-6 h-6 bg-purple-100 rounded-full flex items-center justify-center flex-shrink-0">
<Users className="w-3.5 h-3.5 text-purple-600" />
</div>
<span className="font-medium">{event.manager_name}</span>
</div>
)}
{event.hub && (
<div className="flex items-center gap-2 text-sm text-slate-600">
<div className="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
🍽
</div>
<span className="font-medium">{event.hub}</span>
</div>
)}
</div>
{/* Stats Row */}
<div className="flex items-center gap-3 text-sm text-slate-600 mb-4 pb-4 border-b border-slate-100">
<span className="flex items-center gap-1 bg-slate-50 px-2 py-1 rounded-lg">
<RefreshCw className="w-3.5 h-3.5 text-[#0A39DF]" />
<span className="font-semibold text-[#1C323E]">{count}x</span>
</span>
<span className="text-xs text-slate-400"></span>
<span className="text-xs font-medium">Last: {format(parseISO(lastOrdered), "MMM d")}</span>
</div>
{/* Quick Info */}
<div className="space-y-2 text-sm">
{event.event_location && (
<div className="flex items-center gap-2 text-slate-600">
<MapPin className="w-4 h-4 text-slate-400 flex-shrink-0" />
<span className="truncate">{event.event_location}</span>
</div>
)}
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<Users className="w-4 h-4 text-blue-600" />
<span className="font-semibold text-[#1C323E]">{event.requested || 1}</span>
<span className="text-xs text-slate-500">staff</span>
</div>
{event.total && (
<div className="flex items-center gap-1.5">
<DollarSign className="w-4 h-4 text-emerald-600" />
<span className="font-semibold text-[#1C323E]">${event.total.toLocaleString()}</span>
</div>
)}
</div>
</div>
{/* Reorder Button at Bottom */}
<button
onClick={(e) => {
e.stopPropagation();
handleQuickReorder(event);
}}
className="w-full mt-4 bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0829B0] hover:to-[#0F1D26] text-white font-semibold py-3 rounded-xl flex items-center justify-center gap-2 shadow-md hover:shadow-xl transition-all group/reorder"
>
<RefreshCw className="w-4 h-4 group-hover/reorder:rotate-180 transition-transform duration-500" />
Reorder Now
</button>
</div>
{/* Bottom Accent Stripe */}
<div className="h-1.5 bg-gradient-to-r from-[#0A39DF] via-purple-500 to-[#1C323E] group-hover:h-2 transition-all"></div>
</div>
</div>
);
})}
</div>
{/* Scroll Hint */}
<div className="text-center mt-4">
<p className="text-xs text-slate-400 flex items-center justify-center gap-2">
<span>Swipe to see more</span>
<ArrowRight className="w-3 h-3 animate-pulse" />
</p>
</div>
</div>
</div>
)}
{/* Quick Actions - More Professional */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<Link to={createPageUrl("CreateEvent")} className="block group">
<Card className="border-2 border-[#0A39DF]/20 hover:border-[#0A39DF] hover:shadow-xl transition-all h-full bg-gradient-to-br from-white to-blue-50/50">
<CardContent className="p-8 text-center">
<div className="w-20 h-20 mx-auto mb-5 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-2xl flex items-center justify-center shadow-xl group-hover:scale-110 transition-transform">
<Plus className="w-10 h-10 text-white" />
</div>
<h3 className="font-bold text-[#1C323E] mb-2 text-xl">New Order</h3>
<p className="text-sm text-slate-500">Request staff for your event</p>
</CardContent>
</Card>
</Link>
<Link to={createPageUrl("ClientOrders")} className="block group">
<Card className="border-2 border-slate-200 hover:border-purple-500 hover:shadow-xl transition-all h-full bg-gradient-to-br from-white to-purple-50/50">
<CardContent className="p-8 text-center">
<div className="w-20 h-20 mx-auto mb-5 bg-gradient-to-br from-purple-500 to-purple-700 rounded-2xl flex items-center justify-center shadow-xl group-hover:scale-110 transition-transform">
<Calendar className="w-10 h-10 text-white" />
</div>
<h3 className="font-bold text-[#1C323E] mb-2 text-xl">My Orders</h3>
<p className="text-sm text-slate-500">View all your orders</p>
</CardContent>
</Card>
</Link>
<Link to={createPageUrl("Messages")} className="block group">
<Card className="border-2 border-slate-200 hover:border-emerald-500 hover:shadow-xl transition-all h-full bg-gradient-to-br from-white to-emerald-50/50">
<CardContent className="p-8 text-center">
<div className="w-20 h-20 mx-auto mb-5 bg-gradient-to-br from-emerald-500 to-emerald-700 rounded-2xl flex items-center justify-center shadow-xl group-hover:scale-110 transition-transform">
<MessageSquare className="w-10 h-10 text-white" />
</div>
<h3 className="font-bold text-[#1C323E] mb-2 text-xl">Messages</h3>
<p className="text-sm text-slate-500">Contact support</p>
</CardContent>
</Card>
</Link>
</div>
{/* Stats Cards - More Professional */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card className="border-slate-200 shadow-lg hover:shadow-xl transition-shadow bg-gradient-to-br from-white to-amber-50/50">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-3">
<Clock className="w-10 h-10 text-amber-600" />
<Badge className="bg-amber-100 text-amber-700 text-lg px-3 py-1">{pendingOrders}</Badge>
</div>
<p className="text-sm text-slate-500 mb-1 font-medium">Pending Orders</p>
<p className="text-4xl font-bold text-[#1C323E]">{pendingOrders}</p>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg hover:shadow-xl transition-shadow bg-gradient-to-br from-white to-blue-50/50">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-3">
<Calendar className="w-10 h-10 text-[#0A39DF]" />
<Badge className="bg-blue-100 text-blue-700 text-lg px-3 py-1">{activeOrders}</Badge>
</div>
<p className="text-sm text-slate-500 mb-1 font-medium">Active Orders</p>
<p className="text-4xl font-bold text-[#1C323E]">{activeOrders}</p>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg hover:shadow-xl transition-shadow bg-gradient-to-br from-white to-emerald-50/50">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-3">
<CheckCircle className="w-10 h-10 text-emerald-600" />
<Badge className="bg-emerald-100 text-emerald-700 text-lg px-3 py-1">{completedOrders}</Badge>
</div>
<p className="text-sm text-slate-500 mb-1 font-medium">Completed</p>
<p className="text-4xl font-bold text-[#1C323E]">{completedOrders}</p>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg hover:shadow-xl transition-shadow bg-gradient-to-br from-white to-purple-50/50">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-3">
<FileText className="w-10 h-10 text-purple-600" />
<Badge className="bg-purple-100 text-purple-700 text-lg px-3 py-1">0</Badge>
</div>
<p className="text-sm text-slate-500 mb-1 font-medium">Unpaid Invoices</p>
<p className="text-4xl font-bold text-[#1C323E]">{0}</p>
</CardContent>
</Card>
</div>
{/* Upcoming Events - More Professional */}
<Card className="border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-200">
<CardTitle className="flex items-center gap-3 text-xl">
<Calendar className="w-6 h-6 text-[#0A39DF]" />
Upcoming Events
</CardTitle>
</CardHeader>
<CardContent className="p-6">
{upcomingEvents.length > 0 ? (
<div className="space-y-4">
{upcomingEvents.map((event) => (
<div key={event.id} className="flex items-center justify-between p-5 bg-gradient-to-r from-slate-50 to-blue-50/50 rounded-xl hover:shadow-md transition-all border border-slate-200 hover:border-[#0A39DF]/30">
<div className="flex-1">
<h4 className="font-bold text-[#1C323E] text-lg mb-1">{event.event_name}</h4>
<p className="text-sm text-slate-600 flex items-center gap-2">
<Calendar className="w-4 h-4" />
{format(new Date(event.date), "MMMM dd, yyyy")}
<span className="text-slate-400"></span>
<Users className="w-4 h-4" />
{event.requested || 0} staff requested
</p>
</div>
<Badge className={
event.status === "Confirmed" ? "bg-green-100 text-green-700 text-sm px-4 py-2" :
event.status === "Active" ? "bg-blue-100 text-blue-700 text-sm px-4 py-2" :
"bg-yellow-100 text-yellow-700 text-sm px-4 py-2"
}>
{event.status}
</Badge>
</div>
))}
</div>
) : (
<div className="text-center py-16">
<Calendar className="w-20 h-20 mx-auto text-slate-300 mb-4" />
<p className="text-slate-500 mb-6 text-lg">No upcoming events</p>
<Link to={createPageUrl("CreateEvent")}>
<Button className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0829B0] hover:to-[#12242A] text-lg px-8 py-6 shadow-xl">
<Plus className="w-5 h-5 mr-2" />
Create Your First Order
</Button>
</Link>
</div>
)}
</CardContent>
</Card>
{/* Quick Reorder Modal */}
{selectedEvent && (
<QuickReorderModal
event={selectedEvent}
open={reorderModalOpen}
onOpenChange={setReorderModalOpen}
/>
)}
</div>
{/* Custom Scrollbar Styles */}
<style jsx>{`
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
`}</style>
</div>
);
}

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

394
src/pages/ClientOrders.jsx Normal file
View File

@@ -0,0 +1,394 @@
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 { Calendar as CalendarIcon, MapPin, Users, Clock, DollarSign, FileText, Plus, RefreshCw } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { format, addDays } from "date-fns";
import { useToast } from "@/components/ui/use-toast";
// Imports for QuickReorderModal components (assuming shadcn/ui components)
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar"; // Shadcn Calendar component
import { cn } from "@/lib/utils"; // Utility for merging classNames
// Dummy QuickReorderModal component definition for functionality
// In a real application, this would likely be in its own file (e.g., @/components/modals/QuickReorderModal.jsx)
const QuickReorderModal = ({ event, open, onOpenChange }) => {
const navigate = useNavigate();
const { toast } = useToast();
const [newDate, setNewDate] = useState(event?.date ? new Date(event.date) : new Date());
const [eventName, setEventName] = useState(event?.event_name || "");
const [eventLocation, setEventLocation] = useState(event?.event_location || "");
React.useEffect(() => {
if (event) {
setNewDate(event.date ? new Date(event.date) : addDays(new Date(), 7)); // Suggest a week later if no original date
setEventName(event.event_name || "");
setEventLocation(event.event_location || "");
}
}, [event]);
const handleConfirmReorder = async () => {
if (!newDate) {
toast({
title: "Date Required",
description: "Please select a date for your reordered event.",
variant: "destructive",
});
return;
}
if (!eventName.trim()) {
toast({
title: "Event Name Required",
description: "Please enter a name for your reordered event.",
variant: "destructive",
});
return;
}
const reorderData = {
...event, // Copy all existing event data
event_name: eventName.trim(),
event_location: eventLocation.trim(),
date: newDate.toISOString(), // New date
status: "Pending", // New orders start as pending
id: undefined, // Ensure a new ID is generated by the backend
created_at: undefined,
updated_at: undefined,
// Clear specific fields that might not be relevant for a reorder
assigned: 0,
total: 0,
// Any other fields that should be reset or modified for a new order
};
sessionStorage.setItem('reorderData', JSON.stringify(reorderData));
toast({
title: "Event Reordered",
description: `Successfully prepared reorder for "${eventName}". Redirecting to creation page.`,
});
onOpenChange(false); // Close modal
navigate(createPageUrl("CreateEvent") + "?reorder=true");
};
if (!event) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Quick Reorder: {event.event_name}</DialogTitle>
<DialogDescription>
Confirm details for your new order based on this event. You can modify these further on the next page.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="eventName" className="text-right">
Event Name
</Label>
<Input
id="eventName"
value={eventName}
onChange={(e) => setEventName(e.target.value)}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="eventLocation" className="text-right">
Location
</Label>
<Input
id="eventLocation"
value={eventLocation}
onChange={(e) => setEventLocation(e.target.value)}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="date" className="text-right">
Date
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"col-span-3 justify-start text-left font-normal",
!newDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{newDate ? format(newDate, "PPP") : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={newDate}
onSelect={setNewDate}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleConfirmReorder}>
<RefreshCw className="w-4 h-4 mr-2" />
Reorder Event
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default function ClientOrders() {
const navigate = useNavigate();
const [statusFilter, setStatusFilter] = useState("all");
const [reorderModalOpen, setReorderModalOpen] = useState(false); // New state
const [selectedEvent, setSelectedEvent] = useState(null); // New state
const { toast } = useToast();
const { data: user } = useQuery({
queryKey: ['current-user'],
queryFn: () => base44.auth.me(),
});
const { data: events } = useQuery({
queryKey: ['client-events'],
queryFn: () => base44.entities.Event.list('-date'),
initialData: [],
});
// Filter events by current client
const clientEvents = events.filter(e =>
e.client_email === user?.email || e.created_by === user?.email
);
const filteredEvents = statusFilter === "all"
? clientEvents
: clientEvents.filter(e => e.status?.toLowerCase() === statusFilter);
const getStatusColor = (status) => {
const colors = {
'pending': 'bg-yellow-100 text-yellow-700',
'confirmed': 'bg-green-100 text-green-700',
'active': 'bg-blue-100 text-blue-700',
'completed': 'bg-slate-100 text-slate-700',
'canceled': 'bg-red-100 text-red-700',
'cancelled': 'bg-red-100 text-red-700',
};
return colors[status?.toLowerCase()] || 'bg-slate-100 text-slate-700';
};
const stats = {
total: clientEvents.length,
pending: clientEvents.filter(e => e.status === 'Pending').length,
confirmed: clientEvents.filter(e => e.status === 'Confirmed').length,
completed: clientEvents.filter(e => e.status === 'Completed').length,
};
// Removed the old handleReorder function as per the outline
// const handleReorder = (event) => { /* ... existing logic ... */ };
const handleQuickReorder = (event) => {
setSelectedEvent(event);
setReorderModalOpen(true);
};
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 Orders</h1>
<p className="text-slate-500 mt-1">View and manage your event orders</p>
</div>
<Button
onClick={() => navigate(createPageUrl("CreateEvent"))}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
>
<Plus className="w-4 h-4 mr-2" />
New Order
</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">
<div className="flex items-center justify-between mb-2">
<FileText className="w-8 h-8 text-[#0A39DF]" />
</div>
<p className="text-sm text-slate-500">Total Orders</p>
<p className="text-3xl font-bold text-[#1C323E]">{stats.total}</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">
<CalendarIcon className="w-8 h-8 text-green-600" />
</div>
<p className="text-sm text-slate-500">Confirmed</p>
<p className="text-3xl font-bold text-green-600">{stats.confirmed}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<Users className="w-8 h-8 text-blue-600" />
</div>
<p className="text-sm text-slate-500">Completed</p>
<p className="text-3xl font-bold text-blue-600">{stats.completed}</p>
</CardContent>
</Card>
</div>
{/* Filter Tabs */}
<div className="flex gap-2 mb-6">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
onClick={() => setStatusFilter("all")}
className={statusFilter === "all" ? "bg-[#0A39DF]" : ""}
>
All
</Button>
<Button
variant={statusFilter === "pending" ? "default" : "outline"}
onClick={() => setStatusFilter("pending")}
className={statusFilter === "pending" ? "bg-[#0A39DF]" : ""}
>
Pending
</Button>
<Button
variant={statusFilter === "confirmed" ? "default" : "outline"}
onClick={() => setStatusFilter("confirmed")}
className={statusFilter === "confirmed" ? "bg-[#0A39DF]" : ""}
>
Confirmed
</Button>
<Button
variant={statusFilter === "completed" ? "default" : "outline"}
onClick={() => setStatusFilter("completed")}
className={statusFilter === "completed" ? "bg-[#0A39DF]" : ""}
>
Completed
</Button>
</div>
{/* Orders List */}
<div className="grid grid-cols-1 gap-6">
{filteredEvents.length > 0 ? (
filteredEvents.map((event) => (
<Card key={event.id} className="border-slate-200 hover:shadow-lg transition-shadow">
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<h3 className="text-xl font-bold text-[#1C323E]">{event.event_name}</h3>
<Badge className={getStatusColor(event.status)}>
{event.status}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm text-slate-600 mb-4">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4" />
<span>{event.date ? format(new Date(event.date), 'PPP') : 'Date TBD'}</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
<span>{event.event_location || 'Location TBD'}</span>
</div>
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span>{event.assigned || 0} of {event.requested || 0} staff</span>
</div>
{event.total && (
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4" />
<span className="font-semibold">${event.total.toLocaleString()}</span>
</div>
)}
</div>
</div>
<div className="flex gap-2">
<Button
onClick={() => navigate(createPageUrl("EventDetail") + `?id=${event.id}`)}
variant="outline"
className="hover:bg-[#0A39DF] hover:text-white"
>
View Details
</Button>
<Button
onClick={() => handleQuickReorder(event)}
className="bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 text-white shadow-lg"
size="lg"
>
<RefreshCw className="w-5 h-5 mr-2" />
Reorder
</Button>
</div>
</div>
{event.notes && (
<div className="mt-3 p-3 bg-slate-50 rounded-lg">
<p className="text-sm text-slate-600">{event.notes}</p>
</div>
)}
</CardContent>
</Card>
))
) : (
<Card className="border-slate-200">
<CardContent className="p-12 text-center">
<FileText className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-lg font-semibold text-slate-700 mb-2">No orders found</h3>
<p className="text-slate-500 mb-6">Get started by creating your first order</p>
<Button
onClick={() => navigate(createPageUrl("CreateEvent"))}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
>
<Plus className="w-4 h-4 mr-2" />
Create Order
</Button>
</CardContent>
</Card>
)}
</div>
{/* Quick Reorder Modal */}
{selectedEvent && (
<QuickReorderModal
event={selectedEvent}
open={reorderModalOpen}
onOpenChange={setReorderModalOpen}
/>
)}
</div>
</div>
);
}

121
src/pages/CreateEvent.jsx Normal file
View File

@@ -0,0 +1,121 @@
import React, { useEffect, 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 { ArrowLeft, RefreshCw, Lock } from "lucide-react";
import EventForm from "../components/events/EventForm";
import { Alert, AlertDescription } from "@/components/ui/alert";
export default function CreateEvent() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [reorderData, setReorderData] = useState(null);
const [isReorder, setIsReorder] = useState(false);
// Get current user
const { data: user } = useQuery({
queryKey: ['current-user-create-event'],
queryFn: () => base44.auth.me(),
});
const userRole = user?.user_role || user?.role || "admin";
const isClient = userRole === "client";
const isVendor = userRole === "vendor";
const canCreateEvent = isClient || isVendor;
// Check if this is a reorder
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const reorder = urlParams.get('reorder');
if (reorder === 'true') {
const storedData = sessionStorage.getItem('reorderData');
if (storedData) {
try {
const data = JSON.parse(storedData);
setReorderData(data);
setIsReorder(true);
sessionStorage.removeItem('reorderData');
} catch (e) {
console.error('Failed to parse reorder data:', e);
}
}
}
}, []);
const createEventMutation = useMutation({
mutationFn: (eventData) => base44.entities.Event.create(eventData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
navigate(createPageUrl("Events"));
},
});
const handleSubmit = (eventData) => {
createEventMutation.mutate(eventData);
};
// If user doesn't have permission, show access denied
if (!canCreateEvent) {
return (
<div className="p-4 md:p-8">
<div className="max-w-4xl mx-auto">
<Alert className="bg-red-50 border-red-200">
<Lock className="w-4 h-4 text-red-600" />
<AlertDescription className="text-red-800">
<strong>Access Denied:</strong> Event creation is only available for Client and Vendor users.
</AlertDescription>
</Alert>
<div className="mt-6 text-center">
<Button onClick={() => navigate(createPageUrl("Events"))} variant="outline">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Events
</Button>
</div>
</div>
</div>
);
}
return (
<div className="p-4 md:p-8">
<div className="max-w-6xl 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>
{isReorder && (
<Alert className="mb-4 bg-green-50 border-green-200">
<RefreshCw className="w-4 h-4 text-green-600" />
<AlertDescription className="text-green-800">
<strong>Reordering Event:</strong> Details from your previous order have been pre-filled. Update the date and any other details as needed.
</AlertDescription>
</Alert>
)}
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 mb-2">
{isReorder ? "Reorder Event" : "Create New Event"}
</h1>
<p className="text-slate-600">
{isReorder ? "Review and update the details for your new order" : "Fill in the details to create a new event"}
</p>
</div>
<EventForm
event={reorderData}
onSubmit={handleSubmit}
isSubmitting={createEventMutation.isPending}
currentUser={user}
/>
</div>
</div>
);
}

258
src/pages/CreateTeam.jsx Normal file
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>
);
}

261
src/pages/Dashboard.jsx Normal file
View File

@@ -0,0 +1,261 @@
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 { Users, Building2, UserPlus, TrendingUp, MapPin, Calendar, DollarSign, Award, Target, BarChart3, Shield, Leaf } from "lucide-react";
import StatsCard from "../components/staff/StatsCard";
import EcosystemWheel from "../components/dashboard/EcosystemWheel";
import QuickMetrics from "../components/dashboard/QuickMetrics";
import PageHeader from "../components/common/PageHeader";
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: [],
});
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 Events
</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>
{/* 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>
);
}

213
src/pages/EditBusiness.jsx Normal file
View File

@@ -0,0 +1,213 @@
import React 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 { 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 EditBusiness() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const urlParams = new URLSearchParams(window.location.search);
const businessId = urlParams.get('id');
const { data: allBusinesses, isLoading } = useQuery({
queryKey: ['businesses'],
queryFn: () => base44.entities.Business.list(),
initialData: [],
});
const business = allBusinesses.find(b => b.id === businessId);
const [formData, setFormData] = React.useState({
business_name: "",
contact_name: "",
email: "",
phone: "",
address: "",
notes: ""
});
React.useEffect(() => {
if (business) {
setFormData({
business_name: business.business_name || "",
contact_name: business.contact_name || "",
email: business.email || "",
phone: business.phone || "",
address: business.address || "",
notes: business.notes || ""
});
}
}, [business]);
const updateBusinessMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Business.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['businesses'] });
navigate(createPageUrl("Business"));
},
});
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = (e) => {
e.preventDefault();
updateBusinessMutation.mutate({ id: businessId, data: formData });
};
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 (!business) {
return (
<div className="p-8 text-center">
<h2 className="text-2xl font-bold text-slate-900 mb-4">Business Not Found</h2>
<Button onClick={() => navigate(createPageUrl("Business"))}>
Back to Businesses
</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("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">Edit Business Client</h1>
<p className="text-slate-600">Update information for {business.business_name}</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={updateBusinessMutation.isPending}
className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white shadow-lg"
>
{updateBusinessMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Updating...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Update Business
</>
)}
</Button>
</div>
</form>
</div>
</div>
);
}

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

79
src/pages/EditEvent.jsx Normal file
View File

@@ -0,0 +1,79 @@
import React 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 EventForm from "../components/events/EventForm";
export default function EditEvent() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const urlParams = new URLSearchParams(window.location.search);
const eventId = urlParams.get('id');
const { data: allEvents, isLoading } = useQuery({
queryKey: ['events'],
queryFn: () => base44.entities.Event.list(),
initialData: [],
});
const event = allEvents.find(e => e.id === eventId);
const updateEventMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Event.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
navigate(createPageUrl("Events"));
},
});
const handleSubmit = (eventData) => {
updateEventMutation.mutate({ id: eventId, data: eventData });
};
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-4xl 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>
<EventForm
event={event}
onSubmit={handleSubmit}
isSubmitting={updateEventMutation.isPending}
/>
</div>
</div>
);
}

375
src/pages/EditPartner.jsx Normal file
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>
);
}

239
src/pages/EditSector.jsx Normal file
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>
);
}

79
src/pages/EditStaff.jsx Normal file
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>
);
}

418
src/pages/EditVendor.jsx Normal file
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>
);
}

249
src/pages/EventDetail.jsx Normal file
View File

@@ -0,0 +1,249 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Bell, RefreshCw } from "lucide-react";
import { format } from "date-fns";
import ShiftCard from "../components/events/ShiftCard";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { useToast } from "@/components/ui/use-toast";
const statusColors = {
Draft: "bg-gray-100 text-gray-800",
Active: "bg-green-100 text-green-800",
Pending: "bg-purple-100 text-purple-800",
Confirmed: "bg-blue-100 text-blue-800",
Completed: "bg-slate-100 text-slate-800",
Canceled: "bg-red-100 text-red-800" // Added Canceled status for completeness
};
export default function EventDetail() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showNotifyDialog, setShowNotifyDialog] = useState(false);
const urlParams = new URLSearchParams(window.location.search);
const eventId = urlParams.get('id');
const { toast } = useToast();
const { data: allEvents, isLoading } = useQuery({
queryKey: ['events'],
queryFn: () => base44.entities.Event.list(),
initialData: [],
});
const { data: shifts } = useQuery({
queryKey: ['shifts', eventId],
queryFn: () => base44.entities.Shift.filter({ event_id: eventId }),
initialData: [],
enabled: !!eventId
});
const event = allEvents.find(e => e.id === eventId);
const handleReorder = () => {
if (!event) return; // Should not happen if event is loaded, but for safety
const reorderData = {
event_name: event.event_name,
business_id: event.business_id,
business_name: event.business_name,
hub: event.hub,
event_location: event.event_location,
event_type: event.event_type,
requested: event.requested,
client_name: event.client_name,
client_email: event.client_email,
client_phone: event.client_phone,
client_address: event.client_address,
notes: event.notes,
};
sessionStorage.setItem('reorderData', JSON.stringify(reorderData));
toast({
title: "Reordering Event",
description: `Creating new order based on "${event.event_name}"`,
});
navigate(createPageUrl("CreateEvent") + "?reorder=true");
};
if (isLoading || !event) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full" />
</div>
);
}
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-[1600px] mx-auto">
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" size="icon" onClick={() => navigate(createPageUrl("Events"))}>
<ArrowLeft className="w-5 h-5" />
</Button>
<h1 className="text-2xl font-bold">{event.event_name}</h1>
<div className="flex items-center gap-2 ml-auto">
{(event.status === "Completed" || event.status === "Canceled") && (
<Button
onClick={handleReorder}
className="bg-green-600 hover:bg-green-700 text-white"
>
<RefreshCw className="w-4 h-4 mr-2" />
Reorder
</Button>
)}
<Bell className="w-5 h-5" />
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">
M
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
<CardTitle className="text-base">Order Details</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-4">
<div>
<p className="text-xs text-slate-500">PO number</p>
<p className="font-medium">{event.po_number || event.po || "#RC-36559419"}</p>
</div>
<div>
<p className="text-xs text-slate-500">Data</p>
<p className="font-medium">{event.date ? format(new Date(event.date), "dd.MM.yyyy") : "-"}</p>
</div>
<div>
<p className="text-xs text-slate-500">Status</p>
<Badge className={`${statusColors[event.status]} font-medium mt-1`}>
{event.status}
</Badge>
</div>
<div className="flex gap-2 pt-4">
<Button variant="outline" className="flex-1 text-sm">
Edit Order
</Button>
<Button variant="outline" className="flex-1 text-sm text-red-600 hover:text-red-700">
Cancel Order
</Button>
</div>
</CardContent>
</Card>
<Card className="border-slate-200 lg:col-span-2">
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
<CardTitle className="text-base">Client info</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-2 gap-6">
<div>
<p className="text-xs text-slate-500 mb-1">Client name</p>
<p className="font-medium">{event.client_name || "Legendary"}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">Number</p>
<p className="font-medium">{event.client_phone || "(408) 815-9180"}</p>
</div>
<div className="col-span-2">
<p className="text-xs text-slate-500 mb-1">Address</p>
<p className="font-medium">{event.client_address || event.event_location || "848 E Dash Rd, Ste 264 E San Jose, CA 95122"}</p>
</div>
<div className="col-span-2">
<p className="text-xs text-slate-500 mb-1">Email</p>
<p className="font-medium">{event.client_email || "order@legendarysweetssf.com"}</p>
</div>
</div>
</CardContent>
</Card>
</div>
<Card className="border-slate-200 mb-6">
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
<CardTitle className="text-base">Event: {event.event_name}</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-2 gap-6 text-sm">
<div>
<p className="text-slate-500">Hub</p>
<p className="font-medium">{event.hub || "Hub Name"}</p>
</div>
<div>
<p className="text-slate-500">Name of Department</p>
<p className="font-medium">Department name</p>
</div>
<div className="col-span-2">
<p className="text-slate-500 mb-2">Order Addons</p>
<div className="flex gap-2">
<Badge variant="outline" className="text-xs">Title</Badge>
<Badge variant="outline" className="text-xs">Travel Time</Badge>
<Badge variant="outline" className="text-xs">Meal Provided</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
<div className="space-y-6">
{shifts.length > 0 ? (
shifts.map((shift, idx) => (
<ShiftCard
key={shift.id}
shift={shift}
onNotifyStaff={() => setShowNotifyDialog(true)}
/>
))
) : (
<ShiftCard
shift={{
shift_name: "Shift 1",
assigned_staff: event.assigned_staff || [],
location: event.event_location,
unpaid_break: 0,
price: 23,
amount: 120
}}
onNotifyStaff={() => setShowNotifyDialog(true)}
/>
)}
</div>
<Dialog open={showNotifyDialog} onOpenChange={setShowNotifyDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center justify-center mb-4">
<div className="w-12 h-12 bg-pink-500 rounded-full flex items-center justify-center text-white font-bold text-xl">
L
</div>
</div>
<DialogTitle className="text-center">Notification Name</DialogTitle>
<p className="text-center text-sm text-slate-600">
Order #5 Admin (cancelled/replace) Want to proceed?
</p>
</DialogHeader>
<DialogFooter className="flex gap-3 sm:justify-center">
<Button variant="outline" onClick={() => setShowNotifyDialog(false)} className="flex-1">
Cancel
</Button>
<Button onClick={() => setShowNotifyDialog(false)} className="flex-1 bg-blue-600 hover:bg-blue-700">
Proceed
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
}

561
src/pages/Events.jsx Normal file
View File

@@ -0,0 +1,561 @@
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 { Input } from "@/components/ui/input";
import { Plus, Search, Calendar as CalendarIcon, Eye, Edit, Copy, X, RefreshCw } from "lucide-react";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import StatusCard from "../components/events/StatusCard";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { format, isSameDay, parseISO, isWithinInterval, startOfDay, endOfDay, isValid } from "date-fns";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import EventHoverCard from "../components/events/EventHoverCard";
import QuickAssignPopover from "../components/events/QuickAssignPopover";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useToast } from "@/components/ui/use-toast";
import PageHeader from "../components/common/PageHeader";
const statusColors = {
Draft: "bg-gray-100 text-gray-800",
Active: "bg-green-100 text-green-800",
Pending: "bg-purple-100 text-purple-800",
Confirmed: "bg-blue-100 text-blue-800",
Assigned: "bg-yellow-100 text-yellow-800",
Completed: "bg-slate-100 text-slate-800",
Canceled: "bg-red-100 text-red-800",
Cancelled: "bg-red-100 text-red-800"
};
// Helper function to safely parse dates
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;
}
};
// Helper function to safely format dates
const safeFormatDate = (dateString, formatStr) => {
const date = safeParseDate(dateString);
if (!date) return "-";
try {
return format(date, formatStr);
} catch {
return "-";
}
};
export default function Events() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
const [selectedDates, setSelectedDates] = useState([]);
const [dateRange, setDateRange] = useState(null);
const [selectionMode, setSelectionMode] = useState("multiple");
const [calendarOpen, setCalendarOpen] = useState(false);
const [showAlert, setShowAlert] = useState(true);
const { toast } = useToast();
const { data: events, isLoading } = useQuery({
queryKey: ['events'],
queryFn: () => base44.entities.Event.list('-date'),
initialData: [],
});
const getStatusCounts = () => {
const total = events.length;
const active = events.filter(e => e.status === "Active").length;
const pending = events.filter(e => e.status === "Pending" || e.status === "Assigned").length;
const confirmed = events.filter(e => e.status === "Confirmed").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 },
confirmed: { count: confirmed, percentage: total ? Math.round((confirmed / total) * 100) : 0 },
completed: { count: completed, percentage: total ? Math.round((completed / total) * 100) : 0 },
};
};
const getFilteredEvents = () => {
let filtered = events;
if (selectionMode === "range" && dateRange?.from) {
filtered = filtered.filter(e => {
const eventDate = safeParseDate(e.date);
if (!eventDate) return false;
if (dateRange.to) {
try {
return isWithinInterval(eventDate, {
start: startOfDay(dateRange.from),
end: endOfDay(dateRange.to)
});
} catch {
return false;
}
} else {
try {
return isSameDay(eventDate, dateRange.from);
} catch {
return false;
}
}
});
} else if (selectionMode === "multiple" && selectedDates.length > 0) {
filtered = filtered.filter(e => {
const eventDate = safeParseDate(e.date);
if (!eventDate) return false;
return selectedDates.some(selectedDate => {
try {
return isSameDay(eventDate, selectedDate);
} catch {
return false;
}
});
});
}
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 === "canceled") {
filtered = filtered.filter(e => e.status === "Canceled" || e.status === "Cancelled");
} else if (activeTab === "past") {
filtered = filtered.filter(e => e.status === "Completed");
}
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 getTabCount = (tab) => {
if (tab === "all") return events.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 === "canceled") return events.filter(e => e.status === "Canceled" || e.status === "Cancelled").length;
if (tab === "past") return events.filter(e => e.status === "Completed").length;
return 0;
};
const handleDateSelect = (date) => {
if (selectionMode === "multiple") {
setSelectedDates(prev => {
const exists = prev.some(d => {
try {
return isSameDay(d, date);
} catch {
return false;
}
});
if (exists) {
return prev.filter(d => {
try {
return !isSameDay(d, date);
} catch {
return true;
}
});
} else {
return [...prev, date];
}
});
}
};
const handleRangeSelect = (range) => {
setDateRange(range);
};
const clearDates = () => {
setSelectedDates([]);
setDateRange(null);
setShowAlert(true);
};
const getDateSelectionText = () => {
try {
if (selectionMode === "range" && dateRange?.from) {
if (dateRange.to) {
return `${format(dateRange.from, 'MMM d')} - ${format(dateRange.to, 'MMM d, yyyy')}`;
}
return format(dateRange.from, 'MMM d, yyyy');
} else if (selectionMode === "multiple" && selectedDates.length > 0) {
if (selectedDates.length === 1) {
return format(selectedDates[0], 'MMM d, yyyy');
}
return `${selectedDates.length} dates selected`;
}
} catch {
return "Select dates";
}
return "Select dates";
};
const getEventCountForDate = (date) => {
return events.filter(e => {
const eventDate = safeParseDate(e.date);
if (!eventDate) return false;
try {
return isSameDay(eventDate, date);
} catch {
return false;
}
}).length;
};
React.useEffect(() => {
if (showAlert && (filteredEvents.length > 0 && (selectedDates.length > 0 || dateRange?.from))) {
const timer = setTimeout(() => {
setShowAlert(false);
}, 5000);
return () => clearTimeout(timer);
}
}, [showAlert, filteredEvents.length, selectedDates.length, dateRange]);
const handleReorder = (event) => {
// Create a clean copy of the event for reordering
const reorderData = {
event_name: event.event_name,
business_id: event.business_id,
business_name: event.business_name,
hub: event.hub,
event_location: event.event_location,
event_type: event.event_type,
requested: event.requested,
client_name: event.client_name,
client_email: event.client_email,
client_phone: event.client_phone,
client_address: event.client_address,
notes: event.notes,
};
sessionStorage.setItem('reorderData', JSON.stringify(reorderData));
toast({
title: "Reordering Event",
description: `Creating new order based on "${event.event_name}"`,
});
navigate(createPageUrl("CreateEvent") + "?reorder=true");
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<PageHeader
title="Events Management"
subtitle={`Managing ${events.length} ${events.length === 1 ? 'event' : 'events'}${statusCounts.active.count} active`}
showUnpublished={true}
actions={
<Link to={createPageUrl("CreateEvent")}>
<Button className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg">
<Plus className="w-5 h-5 mr-2" />
Create Event
</Button>
</Link>
}
/>
{/* Enhanced Date Selection Section */}
<div className="bg-white rounded-xl p-6 mb-6 border border-slate-200 shadow-sm">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<h3 className="font-semibold text-[#1C323E] text-lg">Select Event Dates</h3>
<div className="flex items-center gap-2 bg-slate-50 p-1 rounded-lg border border-slate-200">
<Button
size="sm"
variant={selectionMode === "multiple" ? "default" : "ghost"}
onClick={() => {
setSelectionMode("multiple");
setDateRange(null);
}}
className={selectionMode === "multiple" ? "bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white" : "text-slate-600 hover:text-slate-900 hover:bg-white"}
>
Multiple
</Button>
<Button
size="sm"
variant={selectionMode === "range" ? "default" : "ghost"}
onClick={() => {
setSelectionMode("range");
setSelectedDates([]);
}}
className={selectionMode === "range" ? "bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white" : "text-slate-600 hover:text-slate-900 hover:bg-white"}
>
Range
</Button>
<Button
size="sm"
variant="ghost"
onClick={clearDates}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
Clear All
</Button>
</div>
</div>
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="border-[#0A39DF] text-[#0A39DF] hover:bg-[#0A39DF]/5 hover:border-[#0A39DF] font-medium min-w-[200px]"
>
<CalendarIcon className="w-4 h-4 mr-2" />
{getDateSelectionText()}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<div className="bg-white rounded-lg shadow-xl border border-slate-200">
<div className="p-4 border-b border-slate-200 bg-slate-50">
<p className="font-semibold text-slate-900">
{selectionMode === "range" ? "Select Date Range" : "Select Multiple Dates"}
</p>
<p className="text-xs text-slate-500 mt-1">
{selectionMode === "range"
? "Click start date, then end date"
: "Click dates to select/deselect"}
</p>
</div>
<Calendar
mode={selectionMode === "range" ? "range" : "multiple"}
selected={selectionMode === "range" ? dateRange : selectedDates}
onSelect={selectionMode === "range" ? handleRangeSelect : handleDateSelect}
numberOfMonths={2}
modifiers={{
hasEvents: (date) => getEventCountForDate(date) > 0
}}
modifiersStyles={{
hasEvents: {
fontWeight: 'bold',
textDecoration: 'underline',
color: '#0A39DF'
}
}}
className="rounded-md border-0 p-4"
/>
<div className="p-4 border-t border-slate-200 bg-slate-50 flex items-center justify-between">
<p className="text-xs text-slate-500">
<span className="font-bold text-[#0A39DF]">Bold underlined dates</span> have events
</p>
<Button
size="sm"
onClick={() => setCalendarOpen(false)}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
>
Done
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
{(selectedDates.length > 0 || dateRange?.from) && showAlert && filteredEvents.length > 0 && (
<Alert className="bg-[#0A39DF]/5 border-[#0A39DF]/20 relative mt-4">
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-6 w-6 text-[#0A39DF] hover:bg-[#0A39DF]/10"
onClick={() => setShowAlert(false)}
>
<X className="w-4 h-4" />
</Button>
<CalendarIcon className="h-4 w-4 text-[#0A39DF]" />
<AlertDescription className="text-[#0A39DF] font-medium pr-8">
{filteredEvents.length} event{filteredEvents.length !== 1 ? 's' : ''} found for selected date{selectionMode === "multiple" && selectedDates.length > 1 ? 's' : ''}
</AlertDescription>
</Alert>
)}
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
<TabsList className="bg-white border border-slate-200 h-auto p-1 shadow-sm">
<TabsTrigger value="all" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white">
Total Events <span className="ml-2 px-2 py-0.5 rounded-full bg-slate-100 data-[state=active]:bg-white/20 text-slate-700 data-[state=active]:text-white text-xs font-medium">{getTabCount("all")}</span>
</TabsTrigger>
<TabsTrigger value="last_minute" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white">
Last Minute <span className="ml-2 px-2 py-0.5 rounded-full bg-slate-100 data-[state=active]:bg-white/20 text-slate-700 data-[state=active]:text-white text-xs font-medium">{getTabCount("last_minute")}</span>
</TabsTrigger>
<TabsTrigger value="upcoming" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white">
Upcoming <span className="ml-2 px-2 py-0.5 rounded-full bg-slate-100 data-[state=active]:bg-white/20 text-slate-700 data-[state=active]:text-white text-xs font-medium">{getTabCount("upcoming")}</span>
</TabsTrigger>
<TabsTrigger value="active" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white">
Active <span className="ml-2 px-2 py-0.5 rounded-full bg-slate-100 data-[state=active]:bg-white/20 text-slate-700 data-[state=active]:text-white text-xs font-medium">{getTabCount("active")}</span>
</TabsTrigger>
<TabsTrigger value="canceled" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white">
Canceled <span className="ml-2 px-2 py-0.5 rounded-full bg-slate-100 data-[state=active]:bg-white/20 text-slate-700 data-[state=active]:text-white text-xs font-medium">{getTabCount("canceled")}</span>
</TabsTrigger>
<TabsTrigger value="past" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white">
Past <span className="ml-2 px-2 py-0.5 rounded-full bg-slate-100 data-[state=active]:bg-white/20 text-slate-700 data-[state=active]:text-white text-xs font-medium">{getTabCount("past")}</span>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatusCard status="Active" count={statusCounts.active.count} percentage={statusCounts.active.percentage} color="blue" />
<StatusCard status="Pending/Assigned" count={statusCounts.pending.count} percentage={statusCounts.pending.percentage} color="yellow" />
<StatusCard status="Confirmed" count={statusCounts.confirmed.count} percentage={statusCounts.confirmed.percentage} color="green" />
<StatusCard status="Completed" count={statusCounts.completed.count} percentage={statusCounts.completed.percentage} color="gray" />
</div>
<div className="bg-white rounded-xl p-4 mb-6 flex items-center gap-4 border border-slate-200 shadow-sm">
<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 ID, company, event name..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 border-slate-300"
/>
</div>
<Avatar className="w-10 h-10 bg-slate-200">
<AvatarFallback className="bg-slate-200 text-slate-700 font-bold">M</AvatarFallback>
</Avatar>
</div>
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-slate-100 hover:bg-slate-100">
<TableHead className="font-semibold text-slate-700">ID</TableHead>
<TableHead className="font-semibold text-slate-700">Company Name</TableHead>
<TableHead className="font-semibold text-slate-700">Hub</TableHead>
<TableHead className="font-semibold text-slate-700">Status</TableHead>
<TableHead className="font-semibold text-slate-700">Event Date</TableHead>
<TableHead className="font-semibold text-slate-700">Event Name</TableHead>
<TableHead className="font-semibold text-slate-700">PO</TableHead>
<TableHead className="font-semibold text-slate-700 text-center">Requested</TableHead>
<TableHead className="font-semibold text-slate-700 text-center">Assigned</TableHead>
<TableHead className="font-semibold text-slate-700 text-center" style={{width: "200px"}}>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredEvents.length === 0 ? (
<TableRow>
<TableCell colSpan={10} 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>
<p className="text-sm mt-1">Try selecting different dates or adjusting your filters</p>
</TableCell>
</TableRow>
) : (
filteredEvents.map((event) => {
const assignedCount = event.assigned_staff?.length || 0;
const confirmedCount = event.assigned_staff?.filter(s => s.confirmed).length || 0;
return (
<EventHoverCard key={event.id} event={event}>
<TableRow className="hover:bg-slate-50 cursor-pointer transition-colors">
<TableCell className="font-medium text-slate-700">{event.id?.slice(-4).toUpperCase()}</TableCell>
<TableCell className="font-medium">{event.business_name || "Company Name"}</TableCell>
<TableCell>{event.hub || "-"}</TableCell>
<TableCell>
<Badge className={`${statusColors[event.status]} font-medium px-3 py-1`}>
{event.status}
</Badge>
{event.status === "Assigned" && confirmedCount > 0 && (
<Badge variant="outline" className="ml-1 text-xs border-green-500 text-green-700">
{confirmedCount}/{assignedCount}
</Badge>
)}
</TableCell>
<TableCell className="font-bold text-slate-700 text-base">
{safeFormatDate(event.date, "MM/dd/yyyy")}
</TableCell>
<TableCell className="font-medium">{event.event_name}</TableCell>
<TableCell>{event.po || event.po_number || "-"}</TableCell>
<TableCell className="text-center font-semibold">{event.requested || 0}</TableCell>
<TableCell className="text-center">
<QuickAssignPopover event={event}>
<button className={`hover:text-[#0A39DF] font-semibold ${
assignedCount >= event.requested && event.requested > 0 ? 'text-green-600' : 'text-orange-600'
}`}>
{assignedCount}
</button>
</QuickAssignPopover>
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
navigate(createPageUrl(`EventDetail?id=${event.id}`));
}}
className="hover:text-[#0A39DF] hover:bg-[#0A39DF]/10"
title="View Details"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
navigate(createPageUrl(`EditEvent?id=${event.id}`));
}}
className="hover:text-[#0A39DF] hover:bg-[#0A39DF]/10"
title="Edit Event"
>
<Edit className="w-4 h-4" />
</Button>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleReorder(event);
}}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 h-8 text-xs font-semibold"
>
<RefreshCw className="w-3 h-3 mr-1" />
Reorder
</Button>
</div>
</TableCell>
</TableRow>
</EventHoverCard>
);
})
)}
</TableBody>
</Table>
</div>
</div>
</div>
);
}

55
src/pages/Home.jsx Normal file
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>
);
}

380
src/pages/InviteVendor.jsx Normal file
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>
);
}

430
src/pages/Invoices.jsx Normal file
View File

@@ -0,0 +1,430 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } 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 { 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, DollarSign, Search, Eye, Download } from "lucide-react";
import { format, parseISO, isPast } from "date-fns";
import PageHeader from "../components/common/PageHeader";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const statusColors = {
'Open': 'bg-orange-500 text-white',
'Confirmed': 'bg-purple-500 text-white',
'Overdue': 'bg-red-500 text-white',
'Resolved': 'bg-blue-500 text-white',
'Paid': 'bg-green-500 text-white',
'Reconciled': 'bg-yellow-600 text-white',
'Disputed': 'bg-gray-500 text-white',
'Verified': 'bg-teal-500 text-white',
'Pending': 'bg-amber-500 text-white',
};
export default function Invoices() {
const [activeTab, setActiveTab] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
const [selectedInvoice, setSelectedInvoice] = useState(null);
const [showPaymentDialog, setShowPaymentDialog] = useState(false);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [paymentMethod, setPaymentMethod] = useState("");
const queryClient = useQueryClient();
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;
// 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);
}
// Admin, procurement, operator can see all
return invoices;
}, [invoices, userRole, user]);
const updateInvoiceMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Invoice.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices'] });
setShowPaymentDialog(false);
setSelectedInvoice(null);
},
});
const getFilteredInvoices = () => {
let filtered = visibleInvoices;
// Status filter
if (activeTab !== "all") {
const statusMap = {
open: "Open",
disputed: "Disputed",
resolved: "Resolved",
verified: "Verified",
overdue: "Overdue",
reconciled: "Reconciled",
paid: "Paid"
};
filtered = filtered.filter(inv => inv.status === statusMap[activeTab]);
}
// Search filter
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();
// Calculate metrics
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 allTotal = getTotalAmount("all");
const openTotal = getTotalAmount("Open");
const overdueTotal = getTotalAmount("Overdue");
const paidTotal = getTotalAmount("Paid");
const openPercentage = allTotal > 0 ? ((openTotal / allTotal) * 100).toFixed(1) : 0;
const overduePercentage = allTotal > 0 ? ((overdueTotal / allTotal) * 100).toFixed(1) : 0;
const paidPercentage = allTotal > 0 ? ((paidTotal / allTotal) * 100).toFixed(1) : 0;
const handleRecordPayment = () => {
if (selectedInvoice && paymentMethod) {
updateInvoiceMutation.mutate({
id: selectedInvoice.id,
data: {
...selectedInvoice,
status: "Paid",
paid_date: new Date().toISOString().split('T')[0],
payment_method: paymentMethod
}
});
}
};
return (
<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} ${filteredInvoices.length === 1 ? 'invoice' : 'invoices'}$${allTotal.toLocaleString()} total`}
actions={
<>
<Button
onClick={() => setShowPaymentDialog(true)}
variant="outline"
className="bg-yellow-400 hover:bg-yellow-500 text-slate-900 border-0 font-semibold"
>
Record Payment
</Button>
<Button
onClick={() => setShowCreateDialog(true)}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white shadow-lg"
>
<Plus className="w-5 h-5 mr-2" />
Create Invoice
</Button>
</>
}
/>
{/* Status Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
<TabsList className="bg-white border border-slate-200 h-auto p-1">
<TabsTrigger value="all" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white">
All Invoices <Badge variant="secondary" className="ml-2">{getStatusCount("all")}</Badge>
</TabsTrigger>
<TabsTrigger value="open">
Open <Badge variant="secondary" className="ml-2">{getStatusCount("Open")}</Badge>
</TabsTrigger>
<TabsTrigger value="disputed">
Disputed <Badge variant="secondary" className="ml-2">{getStatusCount("Disputed")}</Badge>
</TabsTrigger>
<TabsTrigger value="resolved">
Resolved <Badge variant="secondary" className="ml-2">{getStatusCount("Resolved")}</Badge>
</TabsTrigger>
<TabsTrigger value="verified">
Verified <Badge variant="secondary" className="ml-2">{getStatusCount("Verified")}</Badge>
</TabsTrigger>
<TabsTrigger value="overdue">
Overdue <Badge variant="secondary" className="ml-2">{getStatusCount("Overdue")}</Badge>
</TabsTrigger>
<TabsTrigger value="reconciled">
Reconciled <Badge variant="secondary" className="ml-2">{getStatusCount("Reconciled")}</Badge>
</TabsTrigger>
<TabsTrigger value="paid">
Paid <Badge variant="secondary" className="ml-2">{getStatusCount("Paid")}</Badge>
</TabsTrigger>
</TabsList>
</Tabs>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card className="bg-white border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-sm text-slate-500 mb-1">All</p>
<p className="text-3xl font-bold text-[#1C323E]">${allTotal.toLocaleString()}</p>
</div>
<Badge className="bg-[#1C323E] text-white">{getStatusCount("all")} invoices</Badge>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">
<div className="bg-[#0A39DF] h-2 rounded-full" style={{ width: '100%' }}></div>
</div>
<p className="text-right text-sm font-semibold text-[#1C323E] mt-2">100%</p>
</CardContent>
</Card>
<Card className="bg-white border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-sm text-slate-500 mb-1">Open</p>
<p className="text-3xl font-bold text-[#1C323E]">${openTotal.toLocaleString()}</p>
</div>
<Badge className="bg-orange-500 text-white">{getStatusCount("Open")} invoices</Badge>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">
<div className="bg-orange-500 h-2 rounded-full" style={{ width: `${openPercentage}%` }}></div>
</div>
<p className="text-right text-sm font-semibold text-orange-600 mt-2">{openPercentage}%</p>
</CardContent>
</Card>
<Card className="bg-white border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-sm text-slate-500 mb-1">Overdue</p>
<p className="text-3xl font-bold text-[#1C323E]">${overdueTotal.toLocaleString()}</p>
</div>
<Badge className="bg-red-500 text-white">{getStatusCount("Overdue")} invoices</Badge>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">
<div className="bg-red-500 h-2 rounded-full" style={{ width: `${overduePercentage}%` }}></div>
</div>
<p className="text-right text-sm font-semibold text-red-600 mt-2">{overduePercentage}%</p>
</CardContent>
</Card>
<Card className="bg-white border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-sm text-slate-500 mb-1">Paid</p>
<p className="text-3xl font-bold text-[#1C323E]">${paidTotal.toLocaleString()}</p>
</div>
<Badge className="bg-green-500 text-white">{getStatusCount("Paid")} invoices</Badge>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">
<div className="bg-green-500 h-2 rounded-full" style={{ width: `${paidPercentage}%` }}></div>
</div>
<p className="text-right text-sm font-semibold text-green-600 mt-2">{paidPercentage}%</p>
</CardContent>
</Card>
</div>
{/* Search */}
<div className="bg-white rounded-xl p-4 mb-6 flex items-center gap-4 border border-slate-200">
<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 invoices..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 border-slate-300"
/>
</div>
</div>
{/* Invoices Table */}
<Card className="border-slate-200">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50">
<TableHead className="font-semibold text-slate-700">S #</TableHead>
<TableHead className="font-semibold text-slate-700">Manager Name</TableHead>
<TableHead className="font-semibold text-slate-700">Hub</TableHead>
<TableHead className="font-semibold text-slate-700">Invoice ID</TableHead>
<TableHead className="font-semibold text-slate-700">Cost Center</TableHead>
<TableHead className="font-semibold text-slate-700">Event</TableHead>
<TableHead className="font-semibold text-slate-700">Value $</TableHead>
<TableHead className="font-semibold text-slate-700">Count</TableHead>
<TableHead className="font-semibold text-slate-700">Payment Status</TableHead>
<TableHead className="font-semibold text-slate-700">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredInvoices.length === 0 ? (
<TableRow>
<TableCell colSpan={10} 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, idx) => (
<TableRow key={invoice.id} className="hover:bg-slate-50">
<TableCell>{idx + 1}</TableCell>
<TableCell className="font-medium">{invoice.manager_name || invoice.business_name}</TableCell>
<TableCell>{invoice.hub || "Hub Name"}</TableCell>
<TableCell>
<div>
<p className="font-semibold text-sm">{invoice.invoice_number}</p>
<p className="text-xs text-slate-500">{format(parseISO(invoice.issue_date), 'M.d.yyyy')}</p>
</div>
</TableCell>
<TableCell>{invoice.cost_center || "Cost Center"}</TableCell>
<TableCell>{invoice.event_name || "Events Name"}</TableCell>
<TableCell className="font-semibold">${invoice.amount?.toLocaleString()}</TableCell>
<TableCell>
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
{invoice.item_count || 2}
</Badge>
</TableCell>
<TableCell>
<Badge className={`${statusColors[invoice.status]} font-medium px-3 py-1`}>
{invoice.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="hover:text-[#0A39DF]">
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="hover:text-[#0A39DF]">
<Download className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Record Payment Dialog */}
<Dialog open={showPaymentDialog} onOpenChange={setShowPaymentDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Record Payment</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label>Select Invoice</Label>
<Select onValueChange={(value) => setSelectedInvoice(filteredInvoices.find(i => i.id === value))}>
<SelectTrigger>
<SelectValue placeholder="Choose an invoice" />
</SelectTrigger>
<SelectContent>
{filteredInvoices.filter(i => i.status !== "Paid").map((invoice) => (
<SelectItem key={invoice.id} value={invoice.id}>
{invoice.invoice_number} - ${invoice.amount} ({invoice.status})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Payment Method</Label>
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
<SelectTrigger>
<SelectValue placeholder="Select payment method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Credit Card">Credit Card</SelectItem>
<SelectItem value="ACH">ACH Transfer</SelectItem>
<SelectItem value="Wire Transfer">Wire Transfer</SelectItem>
<SelectItem value="Check">Check</SelectItem>
<SelectItem value="Cash">Cash</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPaymentDialog(false)}>
Cancel
</Button>
<Button
onClick={handleRecordPayment}
disabled={!selectedInvoice || !paymentMethod}
className="bg-[#0A39DF]"
>
Record Payment
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create Invoice Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Invoice</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-slate-600">Invoice creation form coming soon...</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
}

543
src/pages/Layout.jsx Normal file
View File

@@ -0,0 +1,543 @@
import React from "react";
import { Link, useLocation } 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
} 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 { Toaster } from "@/components/ui/toaster";
// Navigation items for each role
const roleNavigationMap = {
admin: [
{ title: "Dashboard", url: createPageUrl("Dashboard"), 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: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ 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: "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: "Dashboard", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
{ 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: "Operators", url: createPageUrl("Business"), icon: Briefcase },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Compliance", url: createPageUrl("WorkforceCompliance"), icon: Shield },
{ title: "Orders", url: createPageUrl("Events"), icon: Clipboard },
{ 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: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
],
sector: [
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
],
client: [
{ title: "Dashboard", url: createPageUrl("ClientDashboard"), icon: LayoutDashboard },
{ title: "My Orders", url: createPageUrl("ClientOrders"), icon: Clipboard },
{ title: "New Order", url: createPageUrl("CreateEvent"), icon: UserPlus },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Partner Rates", url: createPageUrl("VendorRateCard"), icon: DollarSign },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Support", url: createPageUrl("Support"), icon: HelpCircle },
],
vendor: [
{ title: "Dashboard", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
{ title: "Schedule", url: createPageUrl("WorkforceShifts"), icon: Calendar },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Team", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Compliance", url: createPageUrl("VendorCompliance"), icon: Shield },
{ title: "Communications", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Leads", url: createPageUrl("Business"), icon: UserCheck },
{ title: "Tasks", url: createPageUrl("ActivityLog"), icon: CheckSquare },
{ 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: "Dashboard", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ 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 [showNotifications, setShowNotifications] = React.useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
// const { data: user } = useQuery({
// queryKey: ['current-user-layout'],
// queryFn: () => base44.auth.me(),
// });
// Mock user data to prevent redirection and allow local development
const user = {
full_name: "Dev User",
email: "dev@example.com",
user_role: "admin", // You can change this to 'procurement', 'operator', 'client', etc. to test different navigation menus
profile_picture: "https://i.pravatar.cc/150?u=a042581f4e29026024d",
};
// 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;
// Get unread notification count
// const { data: unreadCount = 0 } = useQuery({
// queryKey: ['unread-notifications', user?.id],
// queryFn: async () => {
// if (!user?.id) return 0;
// // Assuming ActivityLog entity is used for user notifications
// // and has user_id and is_read fields.
// const notifications = await base44.entities.ActivityLog.filter({
// user_id: user?.id,
// is_read: false
// });
// return notifications.length;
// },
// enabled: !!user?.id,
// initialData: 0,
// refetchInterval: 10000, // Refresh every 10 seconds
// });
const unreadCount = 0; // Mocked value
const userRole = user?.user_role || user?.role || "admin";
const userName = user?.full_name || user?.email || "User";
const userInitial = userName.charAt(0).toUpperCase();
const roleDescription = getRoleDescription(userRole);
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;
}
`}</style>
{/* Unified Top Header - Always Visible */}
<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">
{/* Left Section - Menu + Logo + Search */}
<div className="flex items-center gap-4 flex-1">
{/* Mobile Menu Toggle */}
<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>
{/* Logo & Company Name */}
<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>
{/* Search Bar - Desktop */}
<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>
{/* Right Section - Icons */}
<div className="flex items-center gap-2">
{/* Unpublished Changes Indicator */}
<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>
{/* Search Icon - Mobile */}
<Button
variant="ghost"
size="icon"
className="md:hidden hover:bg-slate-100"
title="Search"
>
<Search className="w-5 h-5 text-slate-600" />
</Button>
{/* Notification Bell */}
<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>
{/* Home */}
<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>
{/* Messages */}
<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>
{/* Help */}
<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>
{/* More Menu (3 dots) */}
<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("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>
{/* User Menu */}
<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>
{/* Main Layout with Sidebar */}
<div className="flex flex-1 overflow-hidden">
{/* Desktop Sidebar */}
<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 Content */}
<main className="flex-1 overflow-auto pb-16">
{children}
</main>
</div>
{/* Layer Identifier - Bottom Bar */}
<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>
{/* Notification Panel */}
<NotificationPanel
isOpen={showNotifications}
onClose={() => setShowNotifications(false)}
/>
{/* Live Chat Bubble */}
<ChatBubble />
{/* Role Switcher (Development Tool) */}
<RoleSwitcher />
{/* Toast Notifications */}
<Toaster />
</div>
);
}

524
src/pages/Messages.jsx Normal file
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>
);
}

513
src/pages/Onboarding.jsx Normal file
View File

@@ -0,0 +1,513 @@
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 [step, setStep] = useState(1);
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(' ') || ""
}));
}
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: [],
});
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()
});
return { member, invite };
},
onSuccess: () => {
setStep(4);
toast({
title: "✅ Registration Successful!",
description: "You've been added to the team successfully.",
});
},
onError: (error) => {
toast({
title: "❌ Registration Failed",
description: error.message,
variant: "destructive",
});
},
});
const handleNext = () => {
if (step === 1) {
// Validate basic info
if (!formData.first_name || !formData.last_name || !formData.email || !formData.phone) {
toast({
title: "Missing Information",
description: "Please fill in your name, email, and phone number",
variant: "destructive",
});
return;
}
setStep(2);
} else if (step === 2) {
// Validate additional info
if (!formData.title || !formData.department) {
toast({
title: "Missing Information",
description: "Please fill in your title and department",
variant: "destructive",
});
return;
}
setStep(3);
} else if (step === 3) {
// Validate password
if (!formData.password || formData.password !== formData.confirmPassword) {
toast({
title: "Password Mismatch",
description: "Passwords do not match",
variant: "destructive",
});
return;
}
if (formData.password.length < 6) {
toast({
title: "Password Too Short",
description: "Password must be at least 6 characters",
variant: "destructive",
});
return;
}
registerMutation.mutate(formData);
}
};
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-yellow-200">
<CardContent className="p-12 text-center">
<div className="w-16 h-16 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-6">
<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-2xl 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>
<p className="text-slate-600">
You've been invited by {invite.invited_by} as a <strong>{invite.role}</strong>
</p>
</div>
{/* Progress Steps */}
{step < 4 && (
<div className="flex items-center justify-center mb-8">
<div className="flex items-center gap-2">
<div className={`flex items-center justify-center w-10 h-10 rounded-full ${step >= 1 ? 'bg-[#0A39DF] text-white' : 'bg-slate-200 text-slate-500'}`}>
{step > 1 ? <CheckCircle2 className="w-5 h-5" /> : '1'}
</div>
<div className={`w-20 h-1 ${step >= 2 ? 'bg-[#0A39DF]' : 'bg-slate-200'}`} />
<div className={`flex items-center justify-center w-10 h-10 rounded-full ${step >= 2 ? 'bg-[#0A39DF] text-white' : 'bg-slate-200 text-slate-500'}`}>
{step > 2 ? <CheckCircle2 className="w-5 h-5" /> : '2'}
</div>
<div className={`w-20 h-1 ${step >= 3 ? 'bg-[#0A39DF]' : 'bg-slate-200'}`} />
<div className={`flex items-center justify-center w-10 h-10 rounded-full ${step >= 3 ? 'bg-[#0A39DF] text-white' : 'bg-slate-200 text-slate-500'}`}>
{step > 3 ? <CheckCircle2 className="w-5 h-5" /> : '3'}
</div>
</div>
</div>
)}
{/* Step 1: Basic Information */}
{step === 1 && (
<Card className="border-2 border-slate-200 shadow-xl">
<CardHeader className="bg-gradient-to-r from-slate-50 to-blue-50">
<CardTitle className="flex items-center gap-2">
<User className="w-5 h-5 text-[#0A39DF]" />
Basic Information
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="first_name">First Name *</Label>
<Input
id="first_name"
value={formData.first_name}
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
placeholder="John"
className="mt-2"
/>
</div>
<div>
<Label htmlFor="last_name">Last Name *</Label>
<Input
id="last_name"
value={formData.last_name}
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
placeholder="Doe"
className="mt-2"
/>
</div>
</div>
<div>
<Label htmlFor="email">Email *</Label>
<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>
<Button
onClick={handleNext}
className="w-full bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:opacity-90"
>
Continue
</Button>
</CardContent>
</Card>
)}
{/* Step 2: Work Information */}
{step === 2 && (
<Card className="border-2 border-slate-200 shadow-xl">
<CardHeader className="bg-gradient-to-r from-slate-50 to-blue-50">
<CardTitle className="flex items-center gap-2">
<Briefcase className="w-5 h-5 text-[#0A39DF]" />
Work Information
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-4">
<div>
<Label htmlFor="title">Job Title *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="e.g., Manager, Coordinator, Supervisor"
className="mt-2"
/>
</div>
<div>
<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>
<SelectItem value="Operations">Operations</SelectItem>
<SelectItem value="Sales">Sales</SelectItem>
<SelectItem value="HR">HR</SelectItem>
<SelectItem value="Finance">Finance</SelectItem>
<SelectItem value="IT">IT</SelectItem>
<SelectItem value="Marketing">Marketing</SelectItem>
<SelectItem value="Customer Service">Customer Service</SelectItem>
<SelectItem value="Logistics">Logistics</SelectItem>
<SelectItem value="Management">Management</SelectItem>
<SelectItem value="Other">Other</SelectItem>
</SelectContent>
</Select>
</div>
{hubs.length > 0 && (
<div>
<Label htmlFor="hub">Hub Location (Optional)</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>
</div>
)}
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setStep(1)}
className="flex-1"
>
Back
</Button>
<Button
onClick={handleNext}
className="flex-1 bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:opacity-90"
>
Continue
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Create Password */}
{step === 3 && (
<Card className="border-2 border-slate-200 shadow-xl">
<CardHeader className="bg-gradient-to-r from-slate-50 to-blue-50">
<CardTitle className="flex items-center gap-2">
<Lock className="w-5 h-5 text-[#0A39DF]" />
Create Your Password
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-4">
<div>
<Label htmlFor="password">Password *</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
className="mt-2"
/>
<p className="text-xs text-slate-500 mt-1">Minimum 6 characters</p>
</div>
<div>
<Label htmlFor="confirmPassword">Confirm Password *</Label>
<Input
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
placeholder="••••••••"
className="mt-2"
/>
</div>
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
<h4 className="font-semibold text-[#1C323E] mb-2">Review Your Information:</h4>
<div className="space-y-1 text-sm text-slate-600">
<p><strong>Name:</strong> {formData.first_name} {formData.last_name}</p>
<p><strong>Email:</strong> {formData.email}</p>
<p><strong>Phone:</strong> {formData.phone}</p>
<p><strong>Title:</strong> {formData.title}</p>
<p><strong>Department:</strong> {formData.department}</p>
{formData.hub && <p><strong>Hub:</strong> {formData.hub}</p>}
<p><strong>Role:</strong> {invite.role}</p>
</div>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setStep(2)}
className="flex-1"
disabled={registerMutation.isPending}
>
Back
</Button>
<Button
onClick={handleNext}
disabled={registerMutation.isPending}
className="flex-1 bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:opacity-90"
>
{registerMutation.isPending ? 'Creating Account...' : 'Complete Registration'}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 4: Success */}
{step === 4 && (
<Card className="border-2 border-green-200 shadow-xl">
<CardContent className="p-12 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full mb-6">
<CheckCircle2 className="w-12 h-12 text-green-600" />
</div>
<h2 className="text-3xl font-bold text-[#1C323E] mb-4">
Welcome to the Team! 🎉
</h2>
<p className="text-slate-600 mb-2">
Your account has been created successfully!
</p>
<div className="bg-slate-50 p-4 rounded-lg mb-8 text-left">
<h3 className="font-semibold text-[#1C323E] mb-2">Your Profile:</h3>
<div className="space-y-1 text-sm text-slate-600">
<p><strong>Name:</strong> {formData.first_name} {formData.last_name}</p>
<p><strong>Email:</strong> {formData.email}</p>
<p><strong>Title:</strong> {formData.title}</p>
<p><strong>Department:</strong> {formData.department}</p>
{formData.hub && <p><strong>Hub:</strong> {formData.hub}</p>}
<p><strong>Team:</strong> {invite.team_name}</p>
</div>
</div>
<Button
onClick={() => navigate(createPageUrl("Dashboard"))}
className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:opacity-90"
>
Go to Dashboard
</Button>
</CardContent>
</Card>
)}
</div>
</div>
);
}

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,147 @@
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 { Briefcase, Plus, Search, MapPin, DollarSign, Edit } from "lucide-react";
import PageHeader from "../components/common/PageHeader";
export default function PartnerManagement() {
const [searchTerm, setSearchTerm] = useState("");
const { data: partners = [], isLoading } = useQuery({
queryKey: ['partners'],
queryFn: () => base44.entities.Partner.list('-created_date'),
initialData: [],
});
const filteredPartners = partners.filter(p =>
!searchTerm ||
p.partner_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.partner_number?.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="Partner Management"
subtitle={`${filteredPartners.length} partners • Clients across all sectors`}
actions={
<Link to={createPageUrl("AddPartner")}>
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
<Plus className="w-4 h-4 mr-2" />
Add Partner
</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 partners..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</CardContent>
</Card>
{/* Partners 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>
) : filteredPartners.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{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-12 h-12 bg-gradient-to-br from-green-500 to-green-700 rounded-xl flex items-center justify-center text-white font-bold">
<Briefcase className="w-6 h-6" />
</div>
<div className="flex-1">
<h3 className="font-bold text-lg text-[#1C323E] mb-1">
{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">
<Edit className="w-4 h-4" />
</Button>
</Link>
</div>
<div className="space-y-3 mb-4">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Type</span>
<Badge variant="outline">{partner.partner_type}</Badge>
</div>
{partner.sector_name && (
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Sector</span>
<span className="font-semibold text-sm text-[#1C323E]">{partner.sector_name}</span>
</div>
)}
{partner.sites && partner.sites.length > 0 && (
<div className="flex items-center justify-between">
<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}</span>
</div>
)}
{partner.payment_terms && (
<div className="flex items-center justify-between">
<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}</span>
</div>
)}
</div>
<div className="pt-4 border-t border-slate-200">
<Badge className={partner.is_active ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-700"}>
{partner.is_active ? "Active" : "Inactive"}
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<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>
);
}

19
src/pages/Payroll.jsx Normal file
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>
);
}

677
src/pages/Permissions.jsx Normal file
View File

@@ -0,0 +1,677 @@
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 { Checkbox } from "@/components/ui/checkbox";
import { Shield, Search, Save, Info, ChevronDown, ChevronRight, Users, Calendar, Package, DollarSign, FileText, Settings as SettingsIcon, BarChart3, MessageSquare, Briefcase, Building2 } from "lucide-react";
import PageHeader from "../components/common/PageHeader";
import { useToast } from "@/components/ui/use-toast";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
// Role-specific permission sets
const ROLE_PERMISSIONS = {
admin: [
{
id: "system",
name: "System Administration",
icon: Shield,
color: "text-red-600",
permissions: [
{ id: "admin.1", name: "Manage All Users", description: "Create, edit, and delete user accounts" },
{ id: "admin.2", name: "Configure System Settings", description: "Modify platform-wide settings" },
{ id: "admin.3", name: "Manage Roles & Permissions", description: "Define and assign user roles" },
{ id: "admin.4", name: "View All Activity Logs", description: "Access complete audit trail" },
{ id: "admin.5", name: "Manage Integrations", description: "Configure external integrations" },
{ id: "admin.6", name: "Access All Data", description: "Unrestricted access to all system data" },
]
},
{
id: "enterprises",
name: "Enterprise Management",
icon: Building2,
color: "text-purple-600",
permissions: [
{ id: "admin.7", name: "Create Enterprises", description: "Onboard new enterprise clients" },
{ id: "admin.8", name: "Manage Sectors", description: "Create and configure sectors" },
{ id: "admin.9", name: "Manage Partners", description: "Onboard and manage partners" },
{ id: "admin.10", name: "Set Global Policies", description: "Define enterprise-wide policies" },
]
},
{
id: "vendors",
name: "Vendor Oversight",
icon: Package,
color: "text-amber-600",
permissions: [
{ id: "admin.11", name: "Approve/Suspend Vendors", description: "Control vendor status" },
{ id: "admin.12", name: "View All Vendor Performance", description: "Access all vendor scorecards" },
{ id: "admin.13", name: "Manage Vendor Rates", description: "Override and approve rate cards" },
]
},
{
id: "financial",
name: "Financial Administration",
icon: DollarSign,
color: "text-green-600",
permissions: [
{ id: "admin.14", name: "View All Financials", description: "Access all financial data" },
{ id: "admin.15", name: "Process All Payments", description: "Approve and process payments" },
{ id: "admin.16", name: "Manage Payroll", description: "Process workforce payroll" },
{ id: "admin.17", name: "Generate Financial Reports", description: "Create P&L and financial analytics" },
]
}
],
procurement: [
{
id: "vendors",
name: "Vendor Management",
icon: Package,
color: "text-purple-600",
permissions: [
{ id: "proc.1", name: "View All Vendors", description: "Access vendor directory" },
{ id: "proc.2", name: "Onboard New Vendors", description: "Add vendors to the platform" },
{ id: "proc.3", name: "Edit Vendor Details", description: "Modify vendor information" },
{ id: "proc.4", name: "Review Vendor Compliance", description: "Check COI, W9, certifications" },
{ id: "proc.5", name: "Approve/Suspend Vendors", description: "Change vendor approval status" },
{ id: "proc.6", name: "View Vendor Performance", description: "Access scorecards and KPIs" },
]
},
{
id: "rates",
name: "Rate Card Management",
icon: DollarSign,
color: "text-green-600",
permissions: [
{ id: "proc.7", name: "View All Rate Cards", description: "See vendor pricing" },
{ id: "proc.8", name: "Create Rate Cards", description: "Set up new rate cards" },
{ id: "proc.9", name: "Edit Rate Cards", description: "Modify existing rates" },
{ id: "proc.10", name: "Approve Rate Cards", description: "Approve vendor rates" },
{ id: "proc.11", name: "Set Markup Rules", description: "Define markup percentages" },
]
},
{
id: "orders",
name: "Order Management",
icon: Calendar,
color: "text-blue-600",
permissions: [
{ id: "proc.12", name: "View All Orders", description: "Access all orders across sectors" },
{ id: "proc.13", name: "Assign Vendors to Orders", description: "Match vendors with orders" },
{ id: "proc.14", name: "Monitor Order Fulfillment", description: "Track order completion" },
]
},
{
id: "reports",
name: "Analytics & Reports",
icon: BarChart3,
color: "text-indigo-600",
permissions: [
{ id: "proc.15", name: "View Vendor Analytics", description: "Access vendor performance data" },
{ id: "proc.16", name: "Generate Procurement Reports", description: "Create spend and compliance reports" },
{ id: "proc.17", name: "Export Data", description: "Download reports as CSV/PDF" },
]
}
],
operator: [
{
id: "events",
name: "Event Management",
icon: Calendar,
color: "text-blue-600",
permissions: [
{ id: "op.1", name: "View My Enterprise Events", description: "See events in my enterprise" },
{ id: "op.2", name: "Create Events", description: "Create new event orders" },
{ id: "op.3", name: "Edit Events", description: "Modify event details" },
{ id: "op.4", name: "Cancel Events", description: "Cancel event orders" },
{ id: "op.5", name: "Approve Events", description: "Approve event requests from sectors" },
{ id: "op.6", name: "View Event Financials", description: "See event costs and billing" },
]
},
{
id: "sectors",
name: "Sector Management",
icon: Building2,
color: "text-cyan-600",
permissions: [
{ id: "op.7", name: "View My Sectors", description: "See sectors under my enterprise" },
{ id: "op.8", name: "Manage Sector Settings", description: "Configure sector policies" },
{ id: "op.9", name: "Assign Vendors to Sectors", description: "Approve vendors for sectors" },
]
},
{
id: "workforce",
name: "Workforce Management",
icon: Users,
color: "text-emerald-600",
permissions: [
{ id: "op.10", name: "View Workforce", description: "See staff across my enterprise" },
{ id: "op.11", name: "Assign Staff to Events", description: "Schedule staff for events" },
{ id: "op.12", name: "Approve Timesheets", description: "Review and approve hours" },
{ id: "op.13", name: "View Staff Performance", description: "Access ratings and reviews" },
]
},
{
id: "reports",
name: "Reports",
icon: BarChart3,
color: "text-indigo-600",
permissions: [
{ id: "op.14", name: "View Enterprise Dashboards", description: "Access my enterprise analytics" },
{ id: "op.15", name: "Export Reports", description: "Download reports" },
]
}
],
sector: [
{
id: "events",
name: "Event Management",
icon: Calendar,
color: "text-blue-600",
permissions: [
{ id: "sec.1", name: "View My Sector Events", description: "See events at my location" },
{ id: "sec.2", name: "Create Event Requests", description: "Request new events" },
{ id: "sec.3", name: "Edit My Events", description: "Modify event details" },
{ id: "sec.4", name: "View Event Costs", description: "See event billing information" },
]
},
{
id: "workforce",
name: "Staff Management",
icon: Users,
color: "text-emerald-600",
permissions: [
{ id: "sec.5", name: "View My Location Staff", description: "See staff at my sector" },
{ id: "sec.6", name: "Schedule Staff", description: "Assign staff to shifts" },
{ id: "sec.7", name: "Approve Timesheets", description: "Review hours worked" },
{ id: "sec.8", name: "Rate Staff Performance", description: "Provide performance feedback" },
]
},
{
id: "vendors",
name: "Vendor Relations",
icon: Package,
color: "text-purple-600",
permissions: [
{ id: "sec.9", name: "View Approved Vendors", description: "See vendors available to my sector" },
{ id: "sec.10", name: "View Vendor Rates", description: "Access rate cards" },
{ id: "sec.11", name: "Request Vendor Services", description: "Submit staffing requests" },
]
}
],
client: [
{
id: "orders",
name: "Order Management",
icon: Calendar,
color: "text-blue-600",
permissions: [
{ id: "client.1", name: "View My Orders", description: "See my event orders" },
{ id: "client.2", name: "Create New Orders", description: "Request staffing for events" },
{ id: "client.3", name: "Edit My Orders", description: "Modify order details before confirmation" },
{ id: "client.4", name: "Cancel Orders", description: "Cancel pending or confirmed orders" },
{ id: "client.5", name: "View Order Status", description: "Track order fulfillment" },
]
},
{
id: "vendors",
name: "Vendor Selection",
icon: Package,
color: "text-purple-600",
permissions: [
{ id: "client.6", name: "View Available Vendors", description: "See vendors I can work with" },
{ id: "client.7", name: "View Vendor Rates", description: "See pricing for services" },
{ id: "client.8", name: "Request Specific Vendors", description: "Prefer specific vendors for orders" },
]
},
{
id: "workforce",
name: "Staff Review",
icon: Users,
color: "text-emerald-600",
permissions: [
{ id: "client.9", name: "View Assigned Staff", description: "See who's working my events" },
{ id: "client.10", name: "Rate Staff Performance", description: "Provide feedback on staff" },
{ id: "client.11", name: "Request Staff Changes", description: "Request staff replacements" },
]
},
{
id: "billing",
name: "Billing & Invoices",
icon: DollarSign,
color: "text-green-600",
permissions: [
{ id: "client.12", name: "View My Invoices", description: "Access invoices for my orders" },
{ id: "client.13", name: "Download Invoices", description: "Export invoice PDFs" },
{ id: "client.14", name: "View Spend Analytics", description: "See my spending trends" },
]
}
],
vendor: [
{
id: "orders",
name: "Order Fulfillment",
icon: Calendar,
color: "text-blue-600",
permissions: [
{ id: "vendor.1", name: "View My Orders", description: "See orders assigned to me" },
{ id: "vendor.2", name: "Accept/Decline Orders", description: "Respond to order requests" },
{ id: "vendor.3", name: "Update Order Status", description: "Mark orders as in progress/completed" },
{ id: "vendor.4", name: "View Order Details", description: "Access order requirements" },
]
},
{
id: "workforce",
name: "My Workforce",
icon: Users,
color: "text-emerald-600",
permissions: [
{ id: "vendor.5", name: "View My Staff", description: "See my workforce members" },
{ id: "vendor.6", name: "Add New Staff", description: "Onboard new workers" },
{ id: "vendor.7", name: "Edit Staff Details", description: "Update staff information" },
{ id: "vendor.8", name: "Assign Staff to Orders", description: "Schedule staff for orders" },
{ id: "vendor.9", name: "Manage Staff Compliance", description: "Track certifications and background checks" },
{ id: "vendor.10", name: "View Staff Performance", description: "See ratings and feedback" },
]
},
{
id: "rates",
name: "Rate Management",
icon: DollarSign,
color: "text-green-600",
permissions: [
{ id: "vendor.11", name: "View My Rate Cards", description: "See my approved rates" },
{ id: "vendor.12", name: "Submit Rate Proposals", description: "Propose new rates" },
{ id: "vendor.13", name: "View Rate History", description: "Track rate changes" },
]
},
{
id: "performance",
name: "Performance & Analytics",
icon: BarChart3,
color: "text-indigo-600",
permissions: [
{ id: "vendor.14", name: "View My Scorecard", description: "See my performance metrics" },
{ id: "vendor.15", name: "View Fill Rate", description: "Track order fulfillment rate" },
{ id: "vendor.16", name: "View Revenue Analytics", description: "See my earnings trends" },
]
},
{
id: "billing",
name: "Invoices & Payments",
icon: FileText,
color: "text-cyan-600",
permissions: [
{ id: "vendor.17", name: "View My Invoices", description: "Access invoices I've issued" },
{ id: "vendor.18", name: "Create Invoices", description: "Generate invoices for completed work" },
{ id: "vendor.19", name: "Track Payments", description: "Monitor payment status" },
]
}
],
workforce: [
{
id: "shifts",
name: "My Shifts",
icon: Calendar,
color: "text-blue-600",
permissions: [
{ id: "work.1", name: "View My Schedule", description: "See my upcoming shifts" },
{ id: "work.2", name: "Clock In/Out", description: "Record shift start and end times" },
{ id: "work.3", name: "Request Time Off", description: "Submit time off requests" },
{ id: "work.4", name: "View Shift History", description: "See past shifts worked" },
]
},
{
id: "profile",
name: "My Profile",
icon: Users,
color: "text-emerald-600",
permissions: [
{ id: "work.5", name: "View My Profile", description: "See my worker profile" },
{ id: "work.6", name: "Edit Contact Info", description: "Update phone/email" },
{ id: "work.7", name: "Update Availability", description: "Set my available days/times" },
{ id: "work.8", name: "Upload Certifications", description: "Add certificates and licenses" },
]
},
{
id: "earnings",
name: "Earnings & Payments",
icon: DollarSign,
color: "text-green-600",
permissions: [
{ id: "work.9", name: "View My Earnings", description: "See my pay and hours" },
{ id: "work.10", name: "View Timesheets", description: "Access my timesheet records" },
{ id: "work.11", name: "View Payment History", description: "See past payments" },
{ id: "work.12", name: "Download Pay Stubs", description: "Export payment records" },
]
},
{
id: "performance",
name: "My Performance",
icon: BarChart3,
color: "text-indigo-600",
permissions: [
{ id: "work.13", name: "View My Ratings", description: "See feedback from clients" },
{ id: "work.14", name: "View Performance Stats", description: "See my reliability metrics" },
{ id: "work.15", name: "View Badges/Achievements", description: "See earned badges" },
]
}
]
};
const ROLE_TEMPLATES = {
admin: {
name: "Administrator",
description: "Full system access",
color: "bg-red-100 text-red-700",
defaultPermissions: "all"
},
procurement: {
name: "Procurement Manager",
description: "Vendor and rate management",
color: "bg-purple-100 text-purple-700",
defaultPermissions: ["proc.1", "proc.2", "proc.3", "proc.4", "proc.5", "proc.6", "proc.7", "proc.8", "proc.9", "proc.10", "proc.12", "proc.13", "proc.15", "proc.16"]
},
operator: {
name: "Operator",
description: "Enterprise management",
color: "bg-blue-100 text-blue-700",
defaultPermissions: ["op.1", "op.2", "op.3", "op.5", "op.6", "op.7", "op.8", "op.10", "op.11", "op.12", "op.14"]
},
sector: {
name: "Sector Manager",
description: "Location-specific management",
color: "bg-cyan-100 text-cyan-700",
defaultPermissions: ["sec.1", "sec.2", "sec.3", "sec.4", "sec.5", "sec.6", "sec.7", "sec.9", "sec.10"]
},
client: {
name: "Client",
description: "Order creation and viewing",
color: "bg-green-100 text-green-700",
defaultPermissions: ["client.1", "client.2", "client.3", "client.5", "client.6", "client.7", "client.9", "client.12"]
},
vendor: {
name: "Vendor Partner",
description: "Vendor-specific access",
color: "bg-amber-100 text-amber-700",
defaultPermissions: ["vendor.1", "vendor.2", "vendor.3", "vendor.5", "vendor.6", "vendor.7", "vendor.8", "vendor.11", "vendor.14", "vendor.17", "vendor.18"]
},
workforce: {
name: "Workforce Member",
description: "Basic access for staff",
color: "bg-slate-100 text-slate-700",
defaultPermissions: ["work.1", "work.2", "work.4", "work.5", "work.6", "work.9", "work.10", "work.13"]
}
};
export default function Permissions() {
const [selectedRole, setSelectedRole] = useState("operator");
const [searchTerm, setSearchTerm] = useState("");
const [expandedCategories, setExpandedCategories] = useState({});
const [permissions, setPermissions] = useState({});
const [overrides, setOverrides] = useState({});
const { toast } = useToast();
const { data: user } = useQuery({
queryKey: ['current-user-permissions'],
queryFn: () => base44.auth.me(),
});
const userRole = user?.user_role || user?.role || "admin";
// Get role-specific permissions
const roleCategories = ROLE_PERMISSIONS[selectedRole] || [];
// Initialize permissions based on role template
React.useEffect(() => {
const template = ROLE_TEMPLATES[selectedRole];
if (template) {
const newPermissions = {};
if (template.defaultPermissions === "all") {
// Admin gets all permissions
roleCategories.forEach(category => {
category.permissions.forEach(perm => {
newPermissions[perm.id] = true;
});
});
} else {
// Other roles get specific permissions
template.defaultPermissions.forEach(permId => {
newPermissions[permId] = true;
});
}
setPermissions(newPermissions);
setOverrides({});
}
}, [selectedRole]);
const toggleCategory = (categoryId) => {
setExpandedCategories(prev => ({
...prev,
[categoryId]: !prev[categoryId]
}));
};
const handlePermissionChange = (permId, value) => {
setPermissions(prev => ({
...prev,
[permId]: value
}));
setOverrides(prev => ({
...prev,
[permId]: true
}));
};
const handleInherit = (permId) => {
const template = ROLE_TEMPLATES[selectedRole];
const shouldBeEnabled = template.defaultPermissions === "all" || template.defaultPermissions.includes(permId);
setPermissions(prev => ({
...prev,
[permId]: shouldBeEnabled
}));
setOverrides(prev => {
const newOverrides = { ...prev };
delete newOverrides[permId];
return newOverrides;
});
};
const handleSave = () => {
toast({
title: "Permissions Saved",
description: `Permissions for ${ROLE_TEMPLATES[selectedRole].name} role have been updated successfully.`,
});
};
const filteredCategories = roleCategories.map(category => ({
...category,
permissions: category.permissions.filter(perm =>
perm.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
perm.description.toLowerCase().includes(searchTerm.toLowerCase())
)
})).filter(category => category.permissions.length > 0);
// Only admins can access this page
if (userRole !== "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 manage permissions.</p>
</div>
);
}
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-6xl mx-auto">
<PageHeader
title="Permissions Management"
subtitle="Configure role-based access control - each role sees only relevant permissions"
/>
{/* Role Selector */}
<Card className="mb-6 border-slate-200 shadow-sm">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-lg">Select Role to Configure</CardTitle>
<p className="text-sm text-slate-500 mt-1">Permissions shown below are contextual to the selected role</p>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{Object.entries(ROLE_TEMPLATES).map(([roleKey, role]) => (
<button
key={roleKey}
onClick={() => setSelectedRole(roleKey)}
className={`p-4 rounded-xl border-2 transition-all text-left ${
selectedRole === roleKey
? 'border-[#0A39DF] bg-blue-50 shadow-md scale-105'
: 'border-slate-200 bg-white hover:border-slate-300 hover:shadow-sm'
}`}
>
<Badge className={role.color + " mb-2"}>{role.name}</Badge>
<p className="text-xs text-slate-600">{role.description}</p>
</button>
))}
</div>
</CardContent>
</Card>
{/* Search */}
<Card className="mb-6 border-slate-200 shadow-sm">
<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 permissions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 border-slate-300"
/>
</div>
</CardContent>
</Card>
{/* Permission Categories */}
<div className="space-y-4">
{filteredCategories.map((category) => {
const Icon = category.icon;
const isExpanded = expandedCategories[category.id] !== false; // Default to expanded
return (
<Card key={category.id} className="border-slate-200 shadow-sm">
<CardHeader
className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100 cursor-pointer hover:bg-slate-100 transition-colors"
onClick={() => toggleCategory(category.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" />
)}
<Icon className={`w-5 h-5 ${category.color}`} />
<CardTitle className="text-base">{category.name}</CardTitle>
<Badge variant="outline" className="text-xs">
{category.permissions.filter(p => permissions[p.id]).length}/{category.permissions.length}
</Badge>
</div>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="p-0">
<div className="divide-y divide-slate-100">
{category.permissions.map((perm) => {
const isOverridden = overrides[perm.id];
const isEnabled = permissions[perm.id];
return (
<div
key={perm.id}
className="flex items-center justify-between p-4 hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-3 flex-1">
<span className="text-sm text-slate-500 font-mono w-16">{perm.id}</span>
<div className="flex items-center gap-2 flex-1">
<span className="text-sm font-medium text-slate-900">{perm.name}</span>
<HoverCard>
<HoverCardTrigger>
<Info className="w-4 h-4 text-slate-400 cursor-help" />
</HoverCardTrigger>
<HoverCardContent className="w-80">
<p className="text-sm text-slate-700">{perm.description}</p>
</HoverCardContent>
</HoverCard>
</div>
</div>
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => handleInherit(perm.id)}
className={`text-xs ${
!isOverridden
? "text-blue-600 hover:text-blue-700 hover:bg-blue-50 font-semibold"
: "text-slate-500 hover:text-slate-700"
}`}
>
Inherit
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handlePermissionChange(perm.id, !isEnabled)}
className={`text-xs ${
isOverridden
? "text-[#0A39DF] hover:text-[#0A39DF]/90 hover:bg-blue-50 font-semibold"
: "text-slate-500 hover:text-slate-700"
}`}
>
Override
</Button>
<Checkbox
checked={isEnabled}
onCheckedChange={(checked) => handlePermissionChange(perm.id, checked)}
className="border-slate-300 data-[state=checked]:bg-[#0A39DF] data-[state=checked]:border-[#0A39DF]"
/>
</div>
</div>
);
})}
</div>
</CardContent>
)}
</Card>
);
})}
</div>
{/* Save Button */}
<div className="flex items-center justify-end gap-4 mt-8 p-6 bg-white rounded-xl border border-slate-200 shadow-sm">
<div className="text-sm text-slate-600">
{Object.keys(overrides).length} permission{Object.keys(overrides).length !== 1 ? 's' : ''} overridden
</div>
<Button onClick={handleSave} className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 shadow-md">
<Save className="w-4 h-4 mr-2" />
Save Permissions
</Button>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

1264
src/pages/Reports.jsx Normal file

File diff suppressed because it is too large Load Diff

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

19
src/pages/Settings.jsx Normal file
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>
);
}

File diff suppressed because it is too large Load Diff

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

19
src/pages/Support.jsx Normal file
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>
);
}

873
src/pages/TeamDetails.jsx Normal file
View File

@@ -0,0 +1,873 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { ArrowLeft, Edit, UserPlus, MapPin, Star, UserX, Search, Mail, Loader2 } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast";
export default function TeamDetails() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const urlParams = new URLSearchParams(window.location.search);
const teamId = urlParams.get('id');
const [activeTab, setActiveTab] = useState("details");
const [memberTab, setMemberTab] = useState("active");
const [searchTerm, setSearchTerm] = useState("");
const [showInviteMemberDialog, setShowInviteMemberDialog] = useState(false);
const [showEditMemberDialog, setShowEditMemberDialog] = useState(false);
const [showAddHubDialog, setShowAddHubDialog] = useState(false);
const [editingMember, setEditingMember] = useState(null);
const [inviteData, setInviteData] = useState({
email: "",
full_name: "",
role: "member",
title: "",
department: "",
hub: ""
});
const [newHub, setNewHub] = useState({
hub_name: "",
address: "",
city: "",
state: "",
zip_code: "",
manager_name: "",
manager_email: ""
});
const { data: user } = useQuery({
queryKey: ['current-user-team-details'],
queryFn: () => base44.auth.me(),
});
const { data: team, isLoading } = useQuery({
queryKey: ['team', teamId],
queryFn: async () => {
const allTeams = await base44.entities.Team.list();
return allTeams.find(t => t.id === teamId);
},
enabled: !!teamId,
});
const { data: members = [] } = useQuery({
queryKey: ['team-members', teamId],
queryFn: async () => {
const allMembers = await base44.entities.TeamMember.list('-created_date');
return allMembers.filter(m => m.team_id === teamId);
},
enabled: !!teamId,
initialData: [],
});
const { data: hubs = [] } = useQuery({
queryKey: ['team-hubs', teamId],
queryFn: async () => {
const allHubs = await base44.entities.TeamHub.list('-created_date');
return allHubs.filter(h => h.team_id === teamId);
},
enabled: !!teamId,
initialData: [],
});
const updateMemberMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.TeamMember.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team-members', teamId] });
setShowEditMemberDialog(false);
setEditingMember(null);
toast({
title: "Member Updated",
description: "Team member updated successfully",
});
},
});
const inviteMemberMutation = useMutation({
mutationFn: async (data) => {
// Generate unique invite code
const inviteCode = `TEAM-${Math.floor(10000 + Math.random() * 90000)}`;
// Create invite record
const invite = await base44.entities.TeamMemberInvite.create({
team_id: teamId,
team_name: team?.team_name,
invite_code: inviteCode,
email: data.email,
full_name: data.full_name,
role: data.role,
invited_by: user?.email || user?.full_name,
invite_status: "pending",
invited_date: new Date().toISOString(),
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7 days
});
// Send email invitation
const acceptUrl = `${window.location.origin}${createPageUrl('TeamDetails')}?id=${teamId}&invite=${inviteCode}`;
await base44.integrations.Core.SendEmail({
from_name: team?.team_name || "Team",
to: data.email,
subject: `You're invited to join ${team?.team_name}!`,
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;">Team Invitation</h1>
<p style="color: #e0f2fe; margin-top: 8px;">Join ${team?.team_name}</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.full_name || 'there'},
</p>
<p style="color: #475569; font-size: 14px; line-height: 1.6; margin-top: 16px;">
<strong>${user?.full_name || user?.email}</strong> has invited you to join <strong>${team?.team_name}</strong> as a <strong>${data.role}</strong>.
</p>
${data.title ? `<p style="color: #475569; font-size: 14px;"><strong>Position:</strong> ${data.title}</p>` : ''}
${data.department ? `<p style="color: #475569; font-size: 14px;"><strong>Department:</strong> ${data.department}</p>` : ''}
${data.hub ? `<p style="color: #475569; font-size: 14px;"><strong>Hub:</strong> ${data.hub}</p>` : ''}
<div style="text-align: center; margin: 32px 0;">
<a href="${acceptUrl}"
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);">
Accept Invitation
</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;">
<strong>📋 Note:</strong> This invitation will expire in 7 days.
</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>
Questions? Contact ${user?.email || 'the team admin'}
</p>
</div>
</div>
`
});
return invite;
},
onSuccess: () => {
setShowInviteMemberDialog(false);
setInviteData({
email: "",
full_name: "",
role: "member",
title: "",
department: "",
hub: ""
});
toast({
title: "Invitation Sent",
description: `Invitation sent to ${inviteData.email}`,
});
},
});
const addHubMutation = useMutation({
mutationFn: (hubData) => base44.entities.TeamHub.create(hubData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team-hubs', teamId] });
queryClient.invalidateQueries({ queryKey: ['team', teamId] });
setShowAddHubDialog(false);
setNewHub({
hub_name: "",
address: "",
city: "",
state: "",
zip_code: "",
manager_name: "",
manager_email: ""
});
toast({
title: "Hub Created",
description: "Hub created successfully",
});
},
});
const handleEditMember = (member) => {
setEditingMember(member);
setShowEditMemberDialog(true);
};
const handleUpdateMember = () => {
updateMemberMutation.mutate({
id: editingMember.id,
data: editingMember
});
};
const handleInviteMember = () => {
inviteMemberMutation.mutate(inviteData);
};
const handleAddHub = () => {
addHubMutation.mutate({
...newHub,
team_id: teamId,
is_active: true
});
};
const activeMembers = members.filter(m => m.is_active);
const deactivatedMembers = members.filter(m => !m.is_active);
const filteredMembers = (memberTab === "active" ? activeMembers : deactivatedMembers).filter(m =>
!searchTerm ||
m.member_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
m.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
m.title?.toLowerCase().includes(searchTerm.toLowerCase())
);
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 team details...</p>
</div>
</div>
);
}
if (!team) {
return (
<div className="p-8 text-center">
<h2 className="text-2xl font-bold text-slate-900 mb-4">Team Not Found</h2>
<Button onClick={() => navigate(createPageUrl("Teams"))}>Back to Teams</Button>
</div>
);
}
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl 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="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Avatar className="w-16 h-16 border-2 border-slate-200">
<AvatarImage src={team.company_logo} />
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white text-2xl font-bold">
{team.team_name?.charAt(0) || 'T'}
</AvatarFallback>
</Avatar>
<div>
<h1 className="text-3xl font-bold text-[#1C323E]">Teams</h1>
<p className="text-slate-500 text-sm mt-1">{team.team_name}</p>
</div>
</div>
<div className="flex gap-2">
<Button
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white"
onClick={() => setShowInviteMemberDialog(true)}
>
<Mail className="w-4 h-4 mr-2" />
Invite team member
</Button>
<Button
className="bg-yellow-400 hover:bg-yellow-500 text-slate-900 font-semibold"
onClick={() => setShowAddHubDialog(true)}
>
<MapPin className="w-4 h-4 mr-2" />
Create hub
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="bg-white border-b border-slate-200 h-auto p-0 rounded-none w-full justify-start">
<TabsTrigger
value="details"
className="data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none px-6 py-3 data-[state=active]:bg-transparent"
>
Details
</TabsTrigger>
<TabsTrigger
value="members"
className="data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none px-6 py-3 data-[state=active]:bg-transparent"
>
Team members
<Badge className="ml-2 bg-[#0A39DF] text-white hover:bg-[#0A39DF]">{members.length}</Badge>
</TabsTrigger>
<TabsTrigger
value="hubs"
className="data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none px-6 py-3 data-[state=active]:bg-transparent"
>
Hubs
<Badge variant="outline" className="ml-2">{hubs.length}</Badge>
</TabsTrigger>
<TabsTrigger
value="favorite"
className="data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none px-6 py-3 data-[state=active]:bg-transparent"
>
Favorite staff
<Badge variant="outline" className="ml-2">{team.favorite_staff_count || 0}</Badge>
</TabsTrigger>
<TabsTrigger
value="blocked"
className="data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none px-6 py-3 data-[state=active]:bg-transparent"
>
Blocked staff
<Badge variant="outline" className="ml-2">{team.blocked_staff_count || 0}</Badge>
</TabsTrigger>
</TabsList>
{/* Details Tab */}
<TabsContent value="details" className="mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Company Logo */}
<Card className="border-slate-200">
<CardContent className="p-6">
<h3 className="font-semibold text-[#1C323E] mb-4">Company Logo</h3>
<div className="flex items-center justify-center">
<Avatar className="w-32 h-32 border-2 border-slate-200">
<AvatarImage src={team.company_logo} />
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white text-5xl font-bold">
{team.team_name?.charAt(0) || 'T'}
</AvatarFallback>
</Avatar>
</div>
</CardContent>
</Card>
{/* Personal Info */}
<Card className="border-slate-200">
<CardContent className="p-6">
<h3 className="font-semibold text-[#1C323E] mb-4">Personal Info</h3>
<div className="space-y-3">
<div>
<Label className="text-xs text-slate-500">Full name:</Label>
<p className="font-semibold">{team.full_name || '-'}</p>
</div>
<div>
<Label className="text-xs text-slate-500">Email:</Label>
<p className="font-semibold">{team.email || '-'}</p>
</div>
<div>
<Label className="text-xs text-slate-500">Phone:</Label>
<p className="font-semibold">{team.phone || '-'}</p>
</div>
</div>
</CardContent>
</Card>
{/* Contact Info */}
<Card className="border-slate-200 md:col-span-2">
<CardContent className="p-6">
<h3 className="font-semibold text-[#1C323E] mb-4">Contact Info</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label className="text-xs text-slate-500">Address:</Label>
<p className="font-semibold">{team.address || '-'}</p>
</div>
<div>
<Label className="text-xs text-slate-500">City:</Label>
<p className="font-semibold">{team.city || '-'}</p>
</div>
<div>
<Label className="text-xs text-slate-500">ZIP Code:</Label>
<p className="font-semibold">{team.zip_code || '-'}</p>
</div>
{team.vendor_id && (
<div>
<Label className="text-xs text-slate-500">Vendor ID:</Label>
<p className="font-semibold">{team.vendor_id}</p>
</div>
)}
</div>
</CardContent>
</Card>
</div>
<div className="flex justify-center mt-6">
<Button variant="outline">
<Edit className="w-4 h-4 mr-2" />
Edit
</Button>
</div>
</TabsContent>
{/* Team Members Tab */}
<TabsContent value="members" className="mt-6">
<Card className="border-slate-200">
<CardContent className="p-6">
{/* 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"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
{/* Active / Deactivated Tabs */}
<Tabs value={memberTab} onValueChange={setMemberTab} className="mb-4">
<TabsList className="bg-transparent border-b border-slate-200 h-auto p-0 rounded-none w-full justify-start">
<TabsTrigger
value="active"
className="data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none px-4 py-2 data-[state=active]:bg-transparent"
>
Active
<Badge className="ml-2 bg-[#0A39DF] text-white hover:bg-[#0A39DF]">{activeMembers.length}</Badge>
</TabsTrigger>
<TabsTrigger
value="deactivated"
className="data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none px-4 py-2 data-[state=active]:bg-transparent"
>
Deactivated
<Badge variant="outline" className="ml-2">{deactivatedMembers.length}</Badge>
</TabsTrigger>
</TabsList>
</Tabs>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Name</TableHead>
<TableHead>Title</TableHead>
<TableHead>Role</TableHead>
<TableHead>Department</TableHead>
<TableHead>Hub</TableHead>
<TableHead className="w-20">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredMembers.length > 0 ? (
filteredMembers.map((member, index) => (
<TableRow key={member.id}>
<TableCell className="font-medium">{index + 1}</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="w-10 h-10">
<AvatarImage src={member.avatar_url} />
<AvatarFallback className="bg-[#0A39DF] text-white">
{member.member_name?.charAt(0) || '?'}
</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">{member.member_name}</p>
<p className="text-xs text-slate-500">{member.email}</p>
</div>
</div>
</TableCell>
<TableCell>{member.title || '-'}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{member.role}
</Badge>
</TableCell>
<TableCell>{member.department || 'No Department'}</TableCell>
<TableCell>{member.hub || 'No Hub'}</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => handleEditMember(member)}
className="hover:text-[#0A39DF] hover:bg-blue-50"
>
<Edit className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-slate-500">
No members found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{filteredMembers.length > 0 && (
<div className="flex items-center justify-center gap-2 mt-4">
<p className="text-sm text-slate-500">1-{filteredMembers.length} of {filteredMembers.length}</p>
<div className="flex gap-1">
<Button variant="outline" size="icon" className="w-8 h-8"></Button>
<Button variant="outline" size="sm" className="w-8 h-8">1</Button>
<Button variant="outline" size="icon" className="w-8 h-8"></Button>
</div>
<Select defaultValue="10">
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10 / page</SelectItem>
<SelectItem value="25">25 / page</SelectItem>
<SelectItem value="50">50 / page</SelectItem>
</SelectContent>
</Select>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Hubs Tab */}
<TabsContent value="hubs" className="mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{hubs.length > 0 ? (
hubs.map((hub) => (
<Card key={hub.id} className="border-slate-200 hover:border-[#0A39DF] hover:shadow-lg transition-all">
<CardContent className="p-6">
<div className="flex items-start gap-3 mb-4">
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center text-white">
<MapPin className="w-6 h-6" />
</div>
<div className="flex-1">
<h3 className="font-bold text-lg text-[#1C323E]">{hub.hub_name}</h3>
{hub.manager_name && (
<p className="text-sm text-slate-500">Manager: {hub.manager_name}</p>
)}
</div>
</div>
<div className="space-y-2 text-sm">
{hub.address && <p className="text-slate-600">{hub.address}</p>}
{hub.city && (
<p className="text-slate-600">
{hub.city}{hub.state ? `, ${hub.state}` : ''} {hub.zip_code}
</p>
)}
{hub.manager_email && (
<p className="text-slate-600">{hub.manager_email}</p>
)}
</div>
</CardContent>
</Card>
))
) : (
<Card className="border-slate-200 col-span-full">
<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 Hubs Yet</h3>
<p className="text-slate-500 mb-6">Create your first hub location</p>
<Button className="bg-yellow-400 hover:bg-yellow-500 text-slate-900 font-semibold" onClick={() => setShowAddHubDialog(true)}>
<MapPin className="w-4 h-4 mr-2" />
Create First Hub
</Button>
</CardContent>
</Card>
)}
</div>
</TabsContent>
{/* Favorite Staff Tab */}
<TabsContent value="favorite" className="mt-6">
<Card className="border-slate-200">
<CardContent className="p-12 text-center">
<Star className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Favorite Staff</h3>
<p className="text-slate-500">Mark staff as favorites to see them here</p>
</CardContent>
</Card>
</TabsContent>
{/* Blocked Staff Tab */}
<TabsContent value="blocked" className="mt-6">
<Card className="border-slate-200">
<CardContent className="p-12 text-center">
<UserX className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Blocked Staff</h3>
<p className="text-slate-500">Blocked staff will appear here</p>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Invite Member Dialog */}
<Dialog open={showInviteMemberDialog} onOpenChange={setShowInviteMemberDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">Invite team member</DialogTitle>
<p className="text-sm text-slate-500 mt-2">
At Instawork, every person has a role, each with its own level of access. <a href="#" className="text-[#0A39DF] hover:underline">Learn more</a>
</p>
</DialogHeader>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>First name</Label>
<Input
value={inviteData.full_name.split(' ')[0] || ""}
onChange={(e) => {
const firstName = e.target.value;
const lastName = inviteData.full_name.split(' ').slice(1).join(' ');
setInviteData({ ...inviteData, full_name: `${firstName} ${lastName}`.trim() });
}}
placeholder=""
/>
</div>
<div>
<Label>Last name</Label>
<Input
value={inviteData.full_name.split(' ').slice(1).join(' ') || ""}
onChange={(e) => {
const firstName = inviteData.full_name.split(' ')[0] || "";
const lastName = e.target.value;
setInviteData({ ...inviteData, full_name: `${firstName} ${lastName}`.trim() });
}}
placeholder=""
/>
</div>
<div>
<Label>Email</Label>
<Input
type="email"
value={inviteData.email}
onChange={(e) => setInviteData({ ...inviteData, email: e.target.value })}
placeholder=""
/>
</div>
<div>
<Label>Access level</Label>
<Select value={inviteData.role} onValueChange={(value) => setInviteData({ ...inviteData, role: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="manager">Shift coordinator</SelectItem>
<SelectItem value="viewer">Booking shift coordinator</SelectItem>
</SelectContent>
</Select>
</div>
{/* These fields are no longer needed for invite, but are part of the mutation payload */}
{/* Keeping hidden inputs or removing them depends on if the backend expects them */}
{/* For now, they can remain in state and simply not be rendered in the UI */}
</div>
<DialogFooter className="mt-6">
<Button variant="outline" onClick={() => setShowInviteMemberDialog(false)}>Cancel</Button>
<Button
onClick={handleInviteMember}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
disabled={!inviteData.email || !inviteData.full_name || inviteMemberMutation.isPending}
>
{inviteMemberMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending invitation...
</>
) : (
"Send invitation"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Member Dialog */}
<Dialog open={showEditMemberDialog} onOpenChange={setShowEditMemberDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit Team Member</DialogTitle>
</DialogHeader>
{editingMember && (
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Full Name *</Label>
<Input
value={editingMember.member_name}
onChange={(e) => setEditingMember({ ...editingMember, member_name: e.target.value })}
placeholder="John Doe"
/>
</div>
<div>
<Label>Email *</Label>
<Input
type="email"
value={editingMember.email}
onChange={(e) => setEditingMember({ ...editingMember, email: e.target.value })}
placeholder="john@example.com"
/>
</div>
<div>
<Label>Phone</Label>
<Input
value={editingMember.phone || ""}
onChange={(e) => setEditingMember({ ...editingMember, phone: e.target.value })}
placeholder="+1 (555) 123-4567"
/>
</div>
<div>
<Label>Title</Label>
<Input
value={editingMember.title || ""}
onChange={(e) => setEditingMember({ ...editingMember, title: e.target.value })}
placeholder="Manager"
/>
</div>
<div>
<Label>Role</Label>
<Select value={editingMember.role} onValueChange={(value) => setEditingMember({ ...editingMember, role: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="manager">Manager</SelectItem>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Department</Label>
<Input
value={editingMember.department || ""}
onChange={(e) => setEditingMember({ ...editingMember, department: e.target.value })}
placeholder="Operations"
/>
</div>
<div className="col-span-2">
<Label>Hub</Label>
<Select value={editingMember.hub || ""} onValueChange={(value) => setEditingMember({ ...editingMember, hub: value })}>
<SelectTrigger>
<SelectValue placeholder="Select hub" />
</SelectTrigger>
<SelectContent>
{hubs.map((hub) => (
<SelectItem key={hub.id} value={hub.hub_name}>{hub.hub_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setShowEditMemberDialog(false)}>Cancel</Button>
<Button
onClick={handleUpdateMember}
className="bg-[#0A39DF]"
disabled={!editingMember?.member_name || !editingMember?.email || updateMemberMutation.isPending}
>
{updateMemberMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Updating...
</>
) : (
<>
<Edit className="w-4 h-4 mr-2" />
Update Member
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Hub Dialog */}
<Dialog open={showAddHubDialog} onOpenChange={setShowAddHubDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create Hub</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<Label>Hub Name *</Label>
<Input
value={newHub.hub_name}
onChange={(e) => setNewHub({ ...newHub, hub_name: e.target.value })}
placeholder="Downtown Office"
/>
</div>
<div className="col-span-2">
<Label>Address</Label>
<Input
value={newHub.address}
onChange={(e) => setNewHub({ ...newHub, address: e.target.value })}
placeholder="123 Main Street"
/>
</div>
<div>
<Label>City</Label>
<Input
value={newHub.city}
onChange={(e) => setNewHub({ ...newHub, city: e.target.value })}
placeholder="San Francisco"
/>
</div>
<div>
<Label>State</Label>
<Input
value={newHub.state}
onChange={(e) => setNewHub({ ...newHub, state: e.target.value })}
placeholder="CA"
/>
</div>
<div>
<Label>ZIP Code</Label>
<Input
value={newHub.zip_code}
onChange={(e) => setNewHub({ ...newHub, zip_code: e.target.value })}
placeholder="94102"
/>
</div>
<div>
<Label>Manager Name</Label>
<Input
value={newHub.manager_name}
onChange={(e) => setNewHub({ ...newHub, manager_name: e.target.value })}
placeholder="Jane Smith"
/>
</div>
<div className="col-span-2">
<Label>Manager Email</Label>
<Input
type="email"
value={newHub.manager_email}
onChange={(e) => setNewHub({ ...newHub, manager_email: e.target.value })}
placeholder="jane@example.com"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddHubDialog(false)}>Cancel</Button>
<Button onClick={handleAddHub} className="bg-yellow-400 hover:bg-yellow-500 text-slate-900" disabled={!newHub.hub_name}>
Create Hub
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
}

1273
src/pages/Teams.jsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,375 @@
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 { 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 } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import UserPermissionsModal from "../components/permissions/UserPermissionsModal"; // Import the new modal component
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; // Import Avatar components
export default function UserManagement() {
const [showInviteDialog, setShowInviteDialog] = useState(false);
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 queryClient = useQueryClient();
const { toast } = useToast();
const { data: users } = useQuery({
queryKey: ['all-users'],
queryFn: async () => {
// Only admins can see all users
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 role and information updated successfully",
});
setShowPermissionsModal(false); // Close the modal on success
setSelectedUser(null); // Clear selected user
},
onError: (error) => {
toast({
title: "Error updating user",
description: error.message || "Failed to update user information.",
variant: "destructive",
});
}
});
const handleInviteUser = async () => {
if (!inviteData.email || !inviteData.full_name) {
toast({
title: "Missing Information",
description: "Please fill in email and full name",
variant: "destructive"
});
return;
}
// In a real system, you would send an invitation email
// For now, we'll just create a user record
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 getRoleColor = (role) => {
const colors = {
admin: "bg-red-100 text-red-700",
procurement: "bg-purple-100 text-purple-700",
operator: "bg-blue-100 text-blue-700",
sector: "bg-cyan-100 text-cyan-700",
client: "bg-green-100 text-green-700",
vendor: "bg-amber-100 text-amber-700",
workforce: "bg-slate-100 text-slate-700",
};
return colors[role] || "bg-slate-100 text-slate-700";
};
const getRoleLabel = (role) => {
const labels = {
admin: "Administrator",
procurement: "Procurement",
operator: "Operator",
sector: "Sector Manager",
client: "Client",
vendor: "Vendor",
workforce: "Workforce"
};
return labels[role] || role;
};
const handleEditPermissions = (user) => {
setSelectedUser(user);
setShowPermissionsModal(true);
};
const handleSavePermissions = async (updatedUser) => {
try {
// Assuming updatedUser contains the ID and the fields to update
// The updateUserMutation already handles base44.entities.User.update and success/error toasts
await updateUserMutation.mutateAsync({ userId: updatedUser.id, data: updatedUser });
} catch (error) {
// Error handling is already in updateUserMutation's onError callback
// No need to duplicate toast here unless specific error handling is required for this modal
}
};
// Only admins can access this page
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>
);
}
// Sample avatar for users without profile pictures
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">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-[#1C323E]">User Management</h1>
<p className="text-slate-500 mt-1">Manage users and assign roles</p>
</div>
<Button
onClick={() => setShowInviteDialog(true)}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
>
<UserPlus className="w-4 h-4 mr-2" />
Invite User
</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 Users</p>
<p className="text-3xl font-bold text-[#1C323E]">{users.length}</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<Shield className="w-8 h-8 text-red-600 mb-2" />
<p className="text-sm text-slate-500">Admins</p>
<p className="text-3xl font-bold text-red-600">
{users.filter(u => u.user_role === 'admin' || u.role === 'admin').length}
</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<Building2 className="w-8 h-8 text-amber-600 mb-2" />
<p className="text-sm text-slate-500">Vendors</p>
<p className="text-3xl font-bold text-amber-600">
{users.filter(u => u.user_role === 'vendor').length}
</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<Users className="w-8 h-8 text-blue-600 mb-2" />
<p className="text-sm text-slate-500">Workforce</p>
<p className="text-3xl font-bold text-blue-600">
{users.filter(u => u.user_role === 'workforce').length}
</p>
</CardContent>
</Card>
</div>
{/* Users List */}
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b">
<CardTitle>All Users</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{users.map((user) => (
<div key={user.id} className="flex items-center justify-between p-4 bg-white border-2 border-slate-200 rounded-lg hover:border-[#0A39DF] transition-all">
<div className="flex items-center gap-4 flex-1">
<Avatar className="w-12 h-12 border-2 border-slate-200">
<AvatarImage src={user.profile_picture || sampleAvatar} alt={user.full_name} />
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white font-bold">
{user.full_name?.charAt(0) || user.email?.charAt(0) || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<h4 className="font-semibold text-[#1C323E]">{user.full_name || 'Unnamed User'}</h4>
<div className="flex items-center gap-3 mt-1">
<span className="text-sm text-slate-500 flex items-center gap-1">
<Mail className="w-3 h-3" />
{user.email}
</span>
{user.company_name && (
<span className="text-sm text-slate-500 flex items-center gap-1">
<Building2 className="w-3 h-3" />
{user.company_name}
</span>
)}
</div>
</div>
<Badge className={getRoleColor(user.user_role || user.role)}>
{getRoleLabel(user.user_role || user.role)}
</Badge>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditPermissions(user)}
className="hover:text-[#0A39DF] hover:bg-blue-50"
title="Edit Permissions"
>
<Shield className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" title="Edit User">
<Edit className="w-4 h-4" />
</Button>
{/* Optionally add a delete button here */}
{/* <Button variant="outline" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50" title="Delete User">
<Trash2 className="w-4 h-4" />
</Button> */}
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Invite User Dialog */}
<Dialog open={showInviteDialog} onOpenChange={setShowInviteDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Invite New User</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<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"
/>
</div>
<div>
<Label>Email *</Label>
<Input
type="email"
value={inviteData.email}
onChange={(e) => setInviteData({ ...inviteData, email: e.target.value })}
placeholder="john@example.com"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Role *</Label>
<Select value={inviteData.user_role} onValueChange={(value) => setInviteData({ ...inviteData, user_role: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Administrator</SelectItem>
<SelectItem value="procurement">Procurement</SelectItem>
<SelectItem value="operator">Operator</SelectItem>
<SelectItem value="sector">Sector Manager</SelectItem>
<SelectItem value="client">Client</SelectItem>
<SelectItem value="vendor">Vendor</SelectItem>
<SelectItem value="workforce">Workforce</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Phone</Label>
<Input
value={inviteData.phone}
onChange={(e) => setInviteData({ ...inviteData, phone: e.target.value })}
placeholder="(555) 123-4567"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Company Name</Label>
<Input
value={inviteData.company_name}
onChange={(e) => setInviteData({ ...inviteData, company_name: e.target.value })}
placeholder="Acme Corp"
/>
</div>
<div>
<Label>Department</Label>
<Input
value={inviteData.department}
onChange={(e) => setInviteData({ ...inviteData, department: e.target.value })}
placeholder="Operations"
/>
</div>
</div>
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<p className="text-sm text-blue-700">
<strong>Note:</strong> The user will receive an email invitation with instructions to set up their account and password.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowInviteDialog(false)}>
Cancel
</Button>
<Button onClick={handleInviteUser} className="bg-[#0A39DF]">
Send Invitation
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Permissions Modal */}
<UserPermissionsModal
user={selectedUser}
open={showPermissionsModal}
onClose={() => {
setShowPermissionsModal(false);
setSelectedUser(null);
}}
onSave={handleSavePermissions}
isSaving={updateUserMutation.isLoading}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,298 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Award, TrendingUp, Users, DollarSign, CheckCircle2, Clock, UserCheck, Mail, Edit } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import PageHeader from "../components/common/PageHeader";
import { Button } from "@/components/ui/button";
import { createPageUrl } from "@/utils";
const performanceTrend = [
{ week: 'W1', attendance: 93, training: 89, quality: 91 },
{ week: 'W2', attendance: 95, training: 92, quality: 93 },
{ week: 'W3', attendance: 94, training: 91, quality: 92 },
{ week: 'W4', attendance: 96, training: 94, quality: 95 },
{ week: 'W5', attendance: 95, training: 92, quality: 94 },
];
const krowScoreBreakdown = [
{ metric: 'Attendance', score: 95, target: 95, status: 'excellent' },
{ metric: 'Training Completion', score: 92, target: 90, status: 'good' },
{ metric: 'Quality Score', score: 94, target: 90, status: 'excellent' },
{ metric: 'Client Satisfaction', score: 88, target: 85, status: 'good' },
{ metric: 'Response Time', score: 91, target: 90, status: 'good' },
];
const recentOrders = [
{ id: '#5439', client: 'Tech Corp', status: 'Completed', amount: '$4,580', date: '2024-01-15' },
{ id: '#5440', client: 'Event Solutions', status: 'In Progress', amount: '$6,200', date: '2024-01-16' },
{ id: '#5441', client: 'Premier Events', status: 'Pending', amount: '$3,750', date: '2024-01-17' },
];
const invoices = [
{ id: 'INV-001', amount: '$12,400', status: 'Paid', dueDate: '2024-01-10' },
{ id: 'INV-002', amount: '$8,950', status: 'Pending', dueDate: '2024-01-20' },
{ id: 'INV-003', amount: '$15,200', status: 'Overdue', dueDate: '2024-01-05' },
];
const teamMembers = [
{ name: "Fernanda Arredondo", role: "Admin", email: "fernanda1999@icloud.com", phone: "(408) 966-8347", status: "Active" },
{ name: "Jon Holt", role: "CEO", email: "jonholt@legendaryexe.com", phone: "(555) 123-4567", status: "Active" },
{ name: "Maria Garcia", role: "Operations Manager", email: "maria@legendaryexe.com", phone: "(555) 234-5678", status: "Active" },
];
export default function VendorDashboard() {
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="Vendor Dashboard - Legendary Event Staffing"
subtitle="Orders, billing, workforce metrics, and KROW score"
/>
{/* 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">
<Award className="w-8 h-8 text-[#0A39DF]" />
<Badge className="bg-green-100 text-green-700">A+</Badge>
</div>
<p className="text-sm text-slate-500">KROW Score</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">
<TrendingUp className="w-8 h-8 text-emerald-600" />
<Badge className="bg-green-100 text-green-700">97%</Badge>
</div>
<p className="text-sm text-slate-500">Fill Rate</p>
<p className="text-3xl font-bold text-[#1C323E]">97%</p>
</CardContent>
</Card>
<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-blue-600" />
<Badge className="bg-blue-100 text-blue-700">+8%</Badge>
</div>
<p className="text-sm text-slate-500">Workforce</p>
<p className="text-3xl font-bold text-[#1C323E]">156</p>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<DollarSign className="w-8 h-8 text-green-600" />
<Badge className="bg-green-100 text-green-700">+12%</Badge>
</div>
<p className="text-sm text-slate-500">Monthly Revenue</p>
<p className="text-3xl font-bold text-[#1C323E]">$89K</p>
</CardContent>
</Card>
</div>
{/* Team Members Section - Moved Higher */}
<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 text-xl">
<UserCheck className="w-6 h-6 text-[#0A39DF]" />
Team members
</CardTitle>
<p className="text-sm text-slate-500 mt-1">
Collaborate effectively with defined roles and permissions. <a href="#" className="text-[#0A39DF] hover:underline">Learn more</a>
</p>
</div>
<Button
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white"
onClick={() => navigate(createPageUrl("Teams"))}
>
<Mail className="w-4 h-4 mr-2" />
Invite team member
</Button>
</div>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{teamMembers.map((member, index) => (
<div key={index} className="flex items-center justify-between p-4 rounded-lg border-2 border-slate-200 hover:border-[#0A39DF] transition-all">
<div className="flex items-center gap-4">
<div className="w-14 h-14 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-full flex items-center justify-center text-white font-bold text-xl">
{member.name.split(' ').map(n => n[0]).join('')}
</div>
<div>
<h4 className="font-bold text-[#1C323E] text-lg">{member.name}</h4>
<p className="text-sm text-slate-600">{member.role}</p>
<p className="text-xs text-slate-500 mt-1">
{member.phone} {member.email}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="border-slate-300 hover:bg-slate-50"
>
<Edit className="w-4 h-4 mr-2" />
Edit
</Button>
</div>
))}
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
{/* Recent Orders */}
<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 Orders</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{recentOrders.map((order, 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-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-lg flex items-center justify-center text-white font-bold text-sm">
{order.id.slice(-2)}
</div>
<div>
<h4 className="font-semibold text-[#1C323E]">{order.client}</h4>
<p className="text-xs text-slate-500">{order.date}</p>
</div>
</div>
<Badge className={`${
order.status === 'Completed' ? 'bg-green-100 text-green-700' :
order.status === 'In Progress' ? 'bg-blue-100 text-blue-700' :
'bg-yellow-100 text-yellow-700'
}`}>
{order.status}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-500">Order {order.id}</span>
<span className="font-bold text-[#0A39DF]">{order.amount}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Billing */}
<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]">Billing</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{invoices.map((invoice, 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-center justify-between mb-2">
<div>
<h4 className="font-semibold text-[#1C323E]">{invoice.id}</h4>
<p className="text-xs text-slate-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
Due: {invoice.dueDate}
</p>
</div>
<Badge className={`${
invoice.status === 'Paid' ? 'bg-green-100 text-green-700' :
invoice.status === 'Pending' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{invoice.status}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-500">Amount</span>
<span className="font-bold text-xl text-[#0A39DF]">{invoice.amount}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* KROW Score Panel */}
<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" />
KROW Score Breakdown
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
{krowScoreBreakdown.map((item, index) => (
<div key={index} className="p-4 rounded-lg bg-slate-50 border border-slate-200">
<div className="flex items-center justify-between mb-2">
<h4 className="font-semibold text-[#1C323E]">{item.metric}</h4>
<div className="flex items-center gap-2">
{item.score >= item.target ? (
<CheckCircle2 className="w-5 h-5 text-green-600" />
) : (
<Clock className="w-5 h-5 text-yellow-600" />
)}
<Badge className={`${
item.status === 'excellent' ? 'bg-green-100 text-green-700' :
'bg-blue-100 text-blue-700'
}`}>
{item.score}%
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full ${
item.score >= item.target ? 'bg-green-500' : 'bg-blue-500'
}`}
style={{ width: `${item.score}%` }}
/>
</div>
<span className="text-xs text-slate-500">Target: {item.target}%</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Performance Trend */}
<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]">Week-over-Week Performance Trend</CardTitle>
</CardHeader>
<CardContent className="p-6">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={performanceTrend}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="week" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="attendance" stroke="#0A39DF" strokeWidth={3} name="Attendance %" />
<Line type="monotone" dataKey="training" stroke="#10b981" strokeWidth={3} name="Training %" />
<Line type="monotone" dataKey="quality" stroke="#f59e0b" strokeWidth={3} name="Quality Score" />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
</div>
);
}

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

809
src/pages/VendorOrders.jsx Normal file
View File

@@ -0,0 +1,809 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Plus, Search, Filter, Download, LayoutGrid, List, Eye, Edit, Trash2, MoreHorizontal, Users, UserPlus, RefreshCw, Copy, Calendar as CalendarIcon, ArrowUpDown, Check, Bell, Send, FileText } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { format, isToday, addDays } from "date-fns";
import { useToast } from "@/components/ui/use-toast";
import { Link, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import EventAssignmentModal from "../components/events/EventAssignmentModal";
const getStatusColor = (order) => {
if (!order.shifts_data || order.shifts_data.length === 0) {
return "bg-orange-500 text-white";
}
let totalNeeded = 0;
let totalAssigned = 0;
order.shifts_data.forEach(shift => {
shift.roles.forEach(role => {
const needed = parseInt(role.count) || 0;
const assigned = role.assignments?.length || 0;
totalNeeded += needed;
totalAssigned += assigned;
});
});
if (totalNeeded === 0) return "bg-orange-500 text-white";
if (totalAssigned === 0) return "bg-orange-500 text-white";
if (totalAssigned < totalNeeded) return "bg-blue-500 text-white";
if (totalAssigned >= totalNeeded) return "bg-green-500 text-white";
return "bg-slate-500 text-white";
};
const getStatusText = (order) => {
if (!order.shifts_data || order.shifts_data.length === 0) return "Pending";
let totalNeeded = 0;
let totalAssigned = 0;
order.shifts_data.forEach(shift => {
shift.roles.forEach(role => {
totalNeeded += parseInt(role.count) || 0;
totalAssigned += role.assignments?.length || 0;
});
});
if (totalAssigned === 0) return "Pending";
if (totalAssigned < totalNeeded) return "Partially Filled";
if (totalAssigned >= totalNeeded && totalNeeded > 0) return "Fully Staffed";
return "Pending";
};
const convertTo12Hour = (time24) => {
if (!time24) return '';
const [hours, minutes] = time24.split(':');
const hour = parseInt(hours);
const ampm = hour >= 12 ? 'PM' : 'AM';
const hour12 = hour % 12 || 12;
return `${hour12}:${minutes} ${ampm}`;
};
export default function VendorOrders() {
const [searchQuery, setSearchQuery] = useState("");
const [filters, setFilters] = useState({ status: "all", hub: "all" });
const [viewMode, setViewMode] = useState("list");
const [sortBy, setSortBy] = useState("date");
const [sortOrder, setSortOrder] = useState("desc");
const [selectedDate, setSelectedDate] = useState(null);
const [notifyingOrders, setNotifyingOrders] = useState(new Set());
const [assignmentModal, setAssignmentModal] = useState({ open: false, order: null });
const queryClient = useQueryClient();
const { toast } = useToast();
const navigate = useNavigate();
const { data: user } = useQuery({
queryKey: ['current-user-vendor-orders'],
queryFn: () => base44.auth.me(),
});
const { data: allOrders = [], isLoading } = useQuery({
queryKey: ['orders'],
queryFn: () => base44.entities.Order.list('-created_date'),
});
const { data: invoices = [] } = useQuery({
queryKey: ['invoices'],
queryFn: () => base44.entities.Invoice.list(),
});
// Filter orders for this vendor only
const orders = allOrders.filter(order =>
order.vendor_id === user?.id ||
order.vendor_name === user?.company_name ||
order.created_by === user?.email
);
const getInvoiceForOrder = (orderId) => {
return invoices.find(inv => inv.order_id === orderId);
};
const sendNotificationToStaff = async (order, assignment) => {
const employees = await base44.entities.Employee.filter({ id: assignment.employee_id });
if (employees.length === 0) return { success: false, method: 'none', reason: 'Employee not found' };
const employee = employees[0];
const formattedDate = assignment.shift_date ?
format(new Date(assignment.shift_date), 'EEEE, MMMM d, yyyy') :
'TBD';
const startTime = assignment.shift_start ? convertTo12Hour(assignment.shift_start) : 'TBD';
const endTime = assignment.shift_end ? convertTo12Hour(assignment.shift_end) : 'TBD';
const emailBody = `
Hi ${assignment.employee_name},
Great news! You've been assigned to a new shift.
EVENT DETAILS:
━━━━━━━━━━━━━━━━━━━━
📅 Event: ${order.event_name}
🏢 Client: ${order.client_business}
📍 Location: ${assignment.location || 'TBD'}
🏠 Hub: ${assignment.hub_location || order.hub_location}
SHIFT DETAILS:
━━━━━━━━━━━━━━━━━━━━
📆 Date: ${formattedDate}
🕐 Time: ${startTime} - ${endTime}
👔 Position: ${assignment.position}
${order.notes ? `\nADDITIONAL NOTES:\n${order.notes}\n` : ''}
Please confirm your availability as soon as possible.
If you have any questions, please contact your manager.
Thank you,
Legendary Event Staffing Team
`.trim();
const smsBody = `🎉 Legendary Event Staffing\n\nYou're assigned to ${order.event_name}!\n📆 ${formattedDate}\n🕐 ${startTime}-${endTime}\n👔 ${assignment.position}\n📍 ${assignment.location || order.hub_location}\n\nPlease confirm ASAP. Check email for full details.`;
let emailSent = false;
let smsSent = false;
if (employee.phone) {
try {
const cleanPhone = employee.phone.replace(/\D/g, '');
await base44.integrations.Core.InvokeLLM({
prompt: `Send an SMS text message to phone number ${cleanPhone} with the following content:\n\n${smsBody}\n\nUse a reliable SMS service to send this message. Return success status.`,
add_context_from_internet: false,
});
smsSent = true;
console.log(`✅ SMS sent to ${employee.phone}`);
} catch (error) {
console.error(`❌ Failed to send SMS to ${employee.phone}:`, error);
}
}
if (employee.email) {
try {
await base44.integrations.Core.SendEmail({
from_name: 'Legendary Event Staffing',
to: employee.email,
subject: `🎉 You've been assigned to ${order.event_name}`,
body: emailBody
});
emailSent = true;
console.log(`✅ Email sent to ${employee.email}`);
} catch (error) {
console.error(`❌ Failed to send email to ${employee.email}:`, error);
}
}
if (emailSent || smsSent) {
const methods = [];
if (smsSent) methods.push('SMS');
if (emailSent) methods.push('Email');
return { success: true, method: methods.join(' & '), employee: employee.full_name };
}
return { success: false, method: 'none', reason: 'No contact info or failed to send' };
};
const handleNotifyStaff = async (order) => {
if (notifyingOrders.has(order.id)) return;
setNotifyingOrders(prev => new Set(prev).add(order.id));
const allAssignments = [];
if (order.shifts_data) {
order.shifts_data.forEach((shift, shiftIdx) => {
shift.roles.forEach(role => {
if (role.assignments && role.assignments.length > 0) {
role.assignments.forEach(assignment => {
allAssignments.push({
...assignment,
shift_date: order.event_date,
shift_start: role.start_time,
shift_end: role.end_time,
position: role.service,
location: shift.address || order.event_address,
hub_location: order.hub_location,
});
});
}
});
});
}
if (allAssignments.length === 0) {
toast({
title: "No staff assigned",
description: "Please assign staff before sending notifications.",
variant: "destructive",
});
setNotifyingOrders(prev => {
const next = new Set(prev);
next.delete(order.id);
return next;
});
return;
}
toast({
title: "Sending notifications...",
description: `Notifying ${allAssignments.length} staff member${allAssignments.length > 1 ? 's' : ''} for "${order.event_name}"...`,
});
let successCount = 0;
const results = [];
for (const assignment of allAssignments) {
const result = await sendNotificationToStaff(order, assignment);
if (result.success) {
successCount++;
results.push(`${result.employee} (${result.method})`);
} else {
results.push(`${assignment.employee_name} (${result.reason})`);
}
}
toast({
title: `Notifications sent!`,
description: `Successfully notified ${successCount} of ${allAssignments.length} staff members for "${order.event_name}".`,
});
setNotifyingOrders(prev => {
const next = new Set(prev);
next.delete(order.id);
return next;
});
};
const handleSort = (field) => {
if (sortBy === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(field);
setSortOrder('asc');
}
};
const handleOpenAssignment = (order) => {
setAssignmentModal({ open: true, order });
};
const handleCloseAssignment = () => {
setAssignmentModal({ open: false, order: null });
};
const filteredOrders = orders.filter(order => {
const matchesSearch = order.event_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
order.client_business?.toLowerCase().includes(searchQuery.toLowerCase()) ||
order.manager?.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = filters.status === 'all' || getStatusText(order) === filters.status;
const matchesHub = filters.hub === 'all' || order.hub_location === filters.hub;
const matchesDate = !selectedDate || order.event_date === format(selectedDate, 'yyyy-MM-dd');
return matchesSearch && matchesStatus && matchesHub && matchesDate;
}).sort((a, b) => {
let compareA, compareB;
switch(sortBy) {
case 'business':
compareA = a.client_business || '';
compareB = b.client_business || '';
break;
case 'status':
compareA = getStatusText(a);
compareB = getStatusText(b);
break;
case 'date':
compareA = a.event_date || '';
compareB = b.event_date || '';
break;
default:
return 0;
}
if (compareA < compareB) return sortOrder === 'asc' ? -1 : 1;
if (compareA > compareB) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
const hubs = [...new Set(orders.map(o => o.hub_location).filter(Boolean))];
const today = format(new Date(), 'yyyy-MM-dd');
const todaysOrders = orders.filter(o => o.event_date === today);
const totalStaffToday = todaysOrders.reduce((sum, order) => {
if (!order.shifts_data) return sum;
return sum + order.shifts_data.reduce((shiftSum, shift) => {
return shiftSum + shift.roles.reduce((roleSum, role) => {
return roleSum + (role.assignments?.length || 0);
}, 0);
}, 0);
}, 0);
const pendingToday = todaysOrders.filter(o => getStatusText(o) === 'Pending').length;
const fullyStaffedToday = todaysOrders.filter(o => getStatusText(o) === 'Fully Staffed').length;
const completedToday = todaysOrders.filter(o => o.status === 'completed').length;
return (
<div className="p-6 md:p-8 space-y-6 bg-slate-50 min-h-screen">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">Orders Dashboard</h1>
<p className="text-slate-500 mt-1">Manage and track all event orders</p>
</div>
<div className="flex gap-2">
<Button variant="outline" className="gap-2">
Draft
</Button>
<Link to={createPageUrl("CreateEvent")}>
<Button className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white shadow-lg gap-2">
<Plus className="w-5 h-5" />
Create Order
</Button>
</Link>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="bg-gradient-to-br from-blue-500 to-blue-600 border-0 text-white">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-blue-100 text-sm mb-2">Total Staff Today</p>
<p className="text-4xl font-bold">{totalStaffToday}</p>
</div>
<Users className="w-12 h-12 text-blue-200 opacity-50" />
</div>
</CardContent>
</Card>
<Card className="bg-white border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500 mb-2">Pending</p>
<p className="text-3xl font-bold text-orange-600">{pendingToday}</p>
</div>
<div className="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center">
<CalendarIcon className="w-6 h-6 text-orange-600" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-white border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500 mb-2">Fully Staffed</p>
<p className="text-3xl font-bold text-green-600">{fullyStaffedToday}</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<Users className="w-6 h-6 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-white border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500 mb-2">Completed</p>
<p className="text-3xl font-bold text-slate-900">{completedToday}</p>
</div>
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center">
<Check className="w-6 h-6 text-slate-700" />
</div>
</div>
</CardContent>
</Card>
</div>
<div className="flex flex-col sm:flex-row gap-4 items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search by event, business, or manager..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-white border-slate-200"
/>
</div>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="gap-2">
<CalendarIcon className="w-4 h-4" />
{selectedDate ? format(selectedDate, 'MMM d, yyyy') : 'Filter by Date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={selectedDate}
onSelect={setSelectedDate}
/>
{selectedDate && (
<div className="p-2 border-t">
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={() => setSelectedDate(null)}
>
Clear Filter
</Button>
</div>
)}
</PopoverContent>
</Popover>
<Select value={filters.status} onValueChange={(value) => setFilters({...filters, status: value})}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="Pending">Pending</SelectItem>
<SelectItem value="Partially Filled">Partially Filled</SelectItem>
<SelectItem value="Fully Staffed">Fully Staffed</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2">
<Button
variant={viewMode === 'cards' ? 'default' : 'outline'}
size="icon"
onClick={() => setViewMode('cards')}
>
<LayoutGrid className="w-4 h-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'outline'}
size="icon"
onClick={() => setViewMode('list')}
>
<List className="w-4 h-4" />
</Button>
</div>
</div>
{viewMode === 'list' ? (
<Card className="bg-white border-slate-200 shadow-sm">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">
<button
onClick={() => handleSort('business')}
className="flex items-center gap-1 hover:text-blue-600"
>
Business
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">Hub</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">Event Name</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">
<button
onClick={() => handleSort('status')}
className="flex items-center gap-1 hover:text-blue-600"
>
Status
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">
<button
onClick={() => handleSort('date')}
className="flex items-center gap-1 hover:text-blue-600"
>
Date
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">Requested</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">Assigned</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">Invoice</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">Actions</th>
</tr>
</thead>
<tbody>
{filteredOrders.map((order) => {
let totalNeeded = 0;
let totalAssigned = 0;
if (order.shifts_data) {
order.shifts_data.forEach(shift => {
shift.roles.forEach(role => {
totalNeeded += parseInt(role.count) || 0;
totalAssigned += role.assignments?.length || 0;
});
});
}
const statusText = getStatusText(order);
const isNotifying = notifyingOrders.has(order.id);
const invoice = getInvoiceForOrder(order.id);
return (
<tr
key={order.id}
className="border-b border-slate-100 hover:bg-slate-50 transition-colors cursor-pointer"
onClick={() => handleOpenAssignment(order)}
>
<td className="px-4 py-3">
<div className="text-sm font-semibold text-slate-900">
{order.client_business || 'N/A'}
</div>
</td>
<td className="px-4 py-3">
<Badge variant="outline" className="text-xs">
{order.hub_location}
</Badge>
</td>
<td className="px-4 py-3">
<div className="text-sm font-medium text-slate-900">
{order.event_name}
</div>
</td>
<td className="px-4 py-3">
<Badge className={`${getStatusColor(order)} rounded-full px-3 text-xs`}>
{statusText}
</Badge>
</td>
<td className="px-4 py-3 text-sm text-slate-600">
{order.event_date ? format(new Date(order.event_date), 'MM/dd/yy') : 'N/A'}
</td>
<td className="px-4 py-3 text-sm text-slate-600">{totalNeeded}</td>
<td className="px-4 py-3">
<Badge className={`${totalAssigned >= totalNeeded && totalNeeded > 0 ? 'bg-green-100 text-green-700' : 'bg-orange-100 text-orange-700'} font-semibold`}>
{totalAssigned}/{totalNeeded}
</Badge>
</td>
<td className="px-4 py-3">
{invoice ? (
<Link to={`${createPageUrl("InvoiceDetail")}?id=${invoice.id}`} onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="sm" className="gap-2 text-blue-600 hover:text-blue-700" title="View Invoice">
<FileText className="w-4 h-4" />
<span className="text-xs font-mono">{invoice.invoice_number}</span>
</Button>
</Link>
) : (
<span className="text-xs text-slate-400"></span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
handleNotifyStaff(order);
}}
disabled={isNotifying || totalAssigned === 0}
title="Notify all assigned staff"
>
{isNotifying ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
handleOpenAssignment(order);
}}
title="Assign staff"
>
<Users className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Edit order"
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Copy order"
>
<Copy className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{invoice && (
<DropdownMenuItem onClick={() => navigate(`${createPageUrl("InvoiceDetail")}?id=${invoice.id}`)}>
<FileText className="w-4 h-4 mr-2" />
View Invoice
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handleNotifyStaff(order)}
disabled={totalAssigned === 0 || isNotifying}
>
<Send className="w-4 h-4 mr-2" />
Notify All Staff
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200">
<p className="text-sm text-slate-600">Showing {filteredOrders.length} orders</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm">Previous</Button>
<Button variant="outline" size="sm">Next</Button>
</div>
</div>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{filteredOrders.map((order) => {
let totalNeeded = 0;
let totalAssigned = 0;
if (order.shifts_data) {
order.shifts_data.forEach(shift => {
shift.roles.forEach(role => {
totalNeeded += parseInt(role.count) || 0;
totalAssigned += role.assignments?.length || 0;
});
});
}
const statusText = getStatusText(order);
const invoice = getInvoiceForOrder(order.id);
return (
<Card
key={order.id}
className="relative hover:shadow-lg transition-all cursor-pointer"
onClick={() => handleOpenAssignment(order)}
>
{invoice && (
<Link to={`${createPageUrl("InvoiceDetail")}?id=${invoice.id}`} onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 z-10 gap-2 bg-white/90 hover:bg-white shadow-sm"
title="View Invoice"
>
<FileText className="w-4 h-4 text-blue-600" />
<span className="text-xs font-mono text-blue-600">{invoice.invoice_number}</span>
</Button>
</Link>
)}
<CardContent className="p-6">
<div className="space-y-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-xl font-bold text-slate-900 mb-1">
{order.event_name}
</h3>
<p className="text-sm text-slate-600">
{order.client_business}
</p>
</div>
<Badge className={`${getStatusColor(order)} rounded-full px-3`}>
{statusText}
</Badge>
</div>
<div className="flex items-center gap-2 text-slate-700">
<CalendarIcon className="w-5 h-5 text-blue-600" />
<span className="font-medium">
{order.event_date ? format(new Date(order.event_date), 'MMMM d, yyyy') : 'No date'}
</span>
</div>
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-purple-600" />
<span className="font-semibold text-slate-700">Staff</span>
</div>
<Badge
className={`${totalAssigned >= totalNeeded && totalNeeded > 0 ? 'bg-green-100 text-green-700' : 'bg-orange-100 text-orange-700'} font-bold border-2`}
style={{
borderColor: totalAssigned >= totalNeeded && totalNeeded > 0 ? '#10b981' : '#f97316'
}}
>
{totalAssigned} / {totalNeeded}
</Badge>
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={(e) => {
e.stopPropagation();
handleNotifyStaff(order);
}}
disabled={totalAssigned === 0}
>
<Send className="w-4 h-4 mr-2" />
Notify Staff
</Button>
{invoice && (
<Link to={`${createPageUrl("InvoiceDetail")}?id=${invoice.id}`} className="flex-1" onClick={(e) => e.stopPropagation()}>
<Button variant="outline" className="w-full">
<FileText className="w-4 h-4 mr-2" />
View Invoice
</Button>
</Link>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{filteredOrders.length === 0 && (
<div className="text-center py-12">
<p className="text-slate-400 text-lg">No orders found</p>
<p className="text-sm text-slate-500 mt-2">Try adjusting your filters</p>
</div>
)}
{/* Event Assignment Modal */}
<EventAssignmentModal
open={assignmentModal.open}
onClose={handleCloseAssignment}
order={assignmentModal.order}
onUpdate={() => queryClient.invalidateQueries({ queryKey: ['orders'] })}
/>
</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>
);
}

644
src/pages/VendorRates.jsx Normal file
View File

@@ -0,0 +1,644 @@
import React, { useMemo, useState } 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 { Badge } from "@/components/ui/badge";
import { Download, Search, Building2, MapPin, DollarSign } from "lucide-react";
import PageHeader from "../components/common/PageHeader";
import { Input } from "@/components/ui/input";
// Define regions - these map to the notes field or can be a new field
const REGIONS = [
"Bay Area",
"Southern California",
"Northern California",
"National"
];
function fmtCurrency(v) {
if (typeof v !== "number" || Number.isNaN(v)) return "—";
return v.toLocaleString(undefined, { style: "currency", currency: "USD" });
}
function downloadCSV(rows, regionName, vendorName) {
const headers = ["Role", "Category", "Employee Wage", "Markup %", "Vendor Fee %", "Client Rate"];
const lines = [headers.join(",")];
for (const r of rows) {
const cells = [
r.role_name,
r.category,
r.employee_wage,
r.markup_percentage,
r.vendor_fee_percentage,
r.client_rate
];
lines.push(cells.join(","));
}
const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${vendorName}_${regionName}_Rates_${new Date().toISOString().slice(0,10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
// NEW: Helper function to parse role name into position and region
const parseRoleName = (roleName) => {
if (!roleName) return { position: '', region: '' };
// Check if role name contains a hyphen separator
if (roleName.includes(' - ')) {
const parts = roleName.split(' - ');
return {
position: parts[0].trim(),
region: parts[1].trim()
};
}
// If no hyphen, return the whole name as position
return {
position: roleName,
region: ''
};
};
// ==================== VENDOR-ONLY COMPONENT ====================
function VendorCompanyPricebookView({ user, vendorName }) {
const APPROVED_TAG = "__APPROVED__";
const [clients, setClients] = useState(["Google", "Zoox", "Promotion"]);
const [pricebook, setPricebook] = useState("Approved");
const [search, setSearch] = useState("");
const [activeRegion, setActiveRegion] = useState("All");
const [activeCategory, setActiveCategory] = useState("All");
const [editing, setEditing] = useState(null);
const [toast, setToast] = useState(null);
const { data: rates = [] } = useQuery({
queryKey: ['vendor-rates-pricebook', vendorName],
queryFn: async () => {
const allRates = await base44.entities.VendorRate.list();
// FOR DEMO: Show Legendary Event Staffing rates if no rates for current vendor
const vendorRates = allRates.filter(r => r.vendor_name === vendorName && r.is_active);
if (vendorRates.length === 0) {
// Show demo data from Legendary Event Staffing
return allRates.filter(r => r.vendor_name === "Legendary Event Staffing" && r.is_active);
}
return vendorRates;
},
initialData: [],
enabled: !!vendorName,
});
const CATEGORIES = [
"Kitchen and Culinary",
"Event Staff",
"Bartending",
"Management",
"Facilities",
"Concessions",
"Other"
];
const scopedByBook = useMemo(() => {
// Convert existing rates to pricebook format
// For now, treat all as "Approved" pricebook
return rates.map(r => ({
...r,
client: APPROVED_TAG,
region: r.notes?.includes("Bay Area") ? "Bay Area" : "LA", // Simplified for demo
approvedCap: r.client_rate,
proposedRate: r.client_rate,
position: r.role_name,
markupPct: r.markup_percentage,
volDiscountPct: r.vendor_fee_percentage
}));
}, [rates]);
const filtered = useMemo(() => {
return scopedByBook.filter(r =>
(activeRegion === "All" || parseRoleName(r.position).region === activeRegion) && // Updated region filter to use parsed region
(activeCategory === "All" || r.category === activeCategory) &&
(search.trim() === "" || parseRoleName(r.position).position.toLowerCase().includes(search.toLowerCase())) // Updated search to use parsed position
);
}, [scopedByBook, activeRegion, activeCategory, search]);
const kpis = useMemo(() => {
const rateValues = filtered.map(r => r.proposedRate);
const avg = rateValues.length ? rateValues.reduce((a, b) => a + b, 0) / rateValues.length : 0;
const min = rateValues.length ? Math.min(...rateValues) : 0;
const max = rateValues.length ? Math.max(...rateValues) : 0;
const total = filtered.length;
return { avg, min, max, total };
}, [filtered]);
function exportCSV() {
const headers = ["Pricebook", "Position", "Category", "Region", "ApprovedCap", "ProposedRate", "Markup%", "VolDiscount%"];
const body = filtered.map(r => {
const parsed = parseRoleName(r.position); // Parse role_name for export
return [
pricebook,
parsed.position,
r.category,
parsed.region,
r.approvedCap,
r.proposedRate,
r.markupPct,
r.volDiscountPct
];
});
const csv = [headers, ...body]
.map(row => row.map(v => (typeof v === "string" ? `"${v}"` : v)).join(","))
.join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `service-rates-${pricebook}.csv`;
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<header className="px-6 py-6 border-b bg-white sticky top-0 z-10">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-slate-900">
<span className="bg-yellow-300 px-2 rounded">Service Rates</span> 20252028
</h1>
<p className="text-slate-500 mt-1">
{rates.length > 0 && rates[0].vendor_name !== vendorName ? (
<>Demo data from <strong>Legendary Event Staffing</strong> - View <strong>Approved Prices</strong></>
) : (
<>{vendorName} - View <strong>Approved Prices</strong> and manage <strong>Company-specific pricebooks</strong></>
)}
</p>
</div>
<Button onClick={exportCSV} variant="outline" className="border-slate-300">
<Download className="w-4 h-4 mr-2" />
Export CSV
</Button>
</div>
</header>
{/* Pricebook Tabs */}
<section className="max-w-7xl mx-auto px-6 mt-6">
<div className="flex flex-wrap gap-2 items-center">
{["Approved", ...clients].map(tab => (
<button
key={tab}
onClick={() => setPricebook(tab)}
className={`px-3 py-1.5 rounded-full text-sm border transition-all ${
pricebook === tab
? "bg-slate-900 text-white border-slate-900"
: "bg-white hover:bg-slate-50"
}`}
>
{tab}
</button>
))}
<span className="text-slate-400 text-sm ml-2">Pricebook</span>
</div>
</section>
{/* KPI Cards */}
<section className="max-w-7xl mx-auto px-6 mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="border-slate-200">
<CardContent className="p-5">
<p className="text-slate-500 text-sm mb-1">Average Rate</p>
<p className="text-2xl font-semibold">{fmtCurrency(kpis.avg)}</p>
<p className="text-xs text-slate-400 mt-2">Based on current filters</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-5">
<p className="text-sm text-slate-500 mb-1">Total Positions</p>
<p className="text-2xl font-semibold">{kpis.total}</p>
<p className="text-xs text-slate-400 mt-2">
{pricebook === "Approved" ? "Approved prices" : `${pricebook} pricebook`}
</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-5">
<p className="text-sm text-slate-500 mb-1">Price Range</p>
<p className="text-2xl font-semibold">
{fmtCurrency(kpis.min)} {fmtCurrency(kpis.max)}
</p>
<p className="text-xs text-slate-400 mt-2">Min Max</p>
</CardContent>
</Card>
</section>
{/* Category Filters + Search */}
<section className="max-w-7xl mx-auto px-6 mt-6">
<div className="flex items-center flex-wrap gap-2">
{["All", ...CATEGORIES].map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className={`px-3 py-1.5 rounded-full text-sm border transition-all ${
activeCategory === cat
? "bg-slate-900 text-white border-slate-900"
: "bg-white hover:bg-slate-50"
}`}
>
{cat}
</button>
))}
<div className="ml-auto w-full md:w-72">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search positions…"
className="w-full"
/>
</div>
</div>
</section>
{/* Table */}
<section className="max-w-7xl mx-auto px-6 mt-4 mb-8">
<div className="overflow-hidden rounded-2xl border bg-white">
<table className="w-full text-left">
<thead className="bg-slate-50">
<tr className="text-slate-500 text-sm">
<th className="px-4 py-3">Position</th>
<th className="px-4 py-3">Category</th>
<th className="px-4 py-3">Region</th>
<th className="px-4 py-3">Employee Wage</th>
<th className="px-4 py-3">Client Rate</th>
<th className="px-4 py-3">Markup %</th>
<th className="px-4 py-3">VA Fee %</th>
<th className="px-4 py-3">Status</th>
</tr>
</thead>
<tbody>
{filtered.map((r, idx) => {
const parsed = parseRoleName(r.position);
return (
<tr key={r.id || idx} className="border-t hover:bg-slate-50/60">
<td className="px-4 py-3 font-medium">{parsed.position}</td>
<td className="px-4 py-3">
<Badge variant="outline" className="text-xs">{r.category}</Badge>
</td>
<td className="px-4 py-3">{parsed.region || '—'}</td>
<td className="px-4 py-3">{fmtCurrency(r.employee_wage)}</td>
<td className="px-4 py-3 font-semibold text-[#0A39DF]">{fmtCurrency(r.proposedRate)}</td>
<td className="px-4 py-3">{r.markupPct}%</td>
<td className="px-4 py-3">{r.volDiscountPct}%</td>
<td className="px-4 py-3">
<Badge className="bg-green-100 text-green-700">Approved</Badge>
</td>
</tr>
);
})}
{filtered.length === 0 && (
<tr>
<td className="px-4 py-8 text-center text-slate-500" colSpan={8}>
No results. Adjust filters or search.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
{/* Toast */}
{toast && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-900 text-white text-sm px-4 py-2 rounded-xl shadow-lg z-50">
{toast}
<button className="ml-3 opacity-70 hover:opacity-100" onClick={() => setToast(null)}></button>
</div>
)}
</div>
);
}
// ==================== ADMIN/PROCUREMENT VIEW (EXISTING) ====================
function AdminProcurementRatesView({ vendorName }) {
const [activeRegion, setActiveRegion] = useState("Bay Area");
const [searchQuery, setSearchQuery] = useState("");
const { data: rates = [], isLoading } = useQuery({
queryKey: ['vendor-rates', vendorName],
queryFn: async () => {
const allRates = await base44.entities.VendorRate.list();
return allRates.filter(r => r.vendor_name === vendorName && r.is_active);
},
initialData: [],
enabled: !!vendorName,
});
const ratesByRegion = useMemo(() => {
const grouped = {};
rates.forEach(rate => {
let region = "National"; // Default region
// Attempt to determine region from rate.notes first
if (rate.notes) {
const notesLower = rate.notes.toLowerCase();
if (notesLower.includes("bay area") || notesLower.includes("san francisco")) {
region = "Bay Area";
} else if (notesLower.includes("southern california") || notesLower.includes("los angeles") || notesLower.includes("san diego")) {
region = "Southern California";
} else if (notesLower.includes("northern california") || notesLower.includes("sacramento")) {
region = "Northern California";
}
}
// If region is not determined by notes, try to parse from role_name
if (region === "National" && parseRoleName(rate.role_name).region) {
region = parseRoleName(rate.role_name).region;
}
if (!grouped[region]) {
grouped[region] = [];
}
grouped[region].push(rate);
});
return grouped;
}, [rates]);
const filteredRates = useMemo(() => {
const regionRates = ratesByRegion[activeRegion] || [];
if (!searchQuery.trim()) return regionRates;
const query = searchQuery.toLowerCase();
return regionRates.filter(r =>
parseRoleName(r.role_name).position.toLowerCase().includes(query) || // Search parsed position
r.category?.toLowerCase().includes(query)
);
}, [ratesByRegion, activeRegion, searchQuery]);
const regionStats = useMemo(() => {
const regionRates = ratesByRegion[activeRegion] || [];
return {
totalRoles: regionRates.length,
avgClientRate: regionRates.length > 0
? regionRates.reduce((sum, r) => sum + (r.client_rate || 0), 0) / regionRates.length
: 0,
avgMarkup: regionRates.length > 0
? regionRates.reduce((sum, r) => sum + (r.markup_percentage || 0), 0) / regionRates.length
: 0,
avgVendorFee: regionRates.length > 0
? regionRates.reduce((sum, r) => sum + (r.vendor_fee_percentage || 0), 0) / regionRates.length
: 0
};
}, [ratesByRegion, activeRegion]);
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<PageHeader
title={`Service Rates 2025-2028`}
subtitle={`${vendorName} - Manage pricing across all clients and positions`}
actions={
<Button
onClick={() => downloadCSV(filteredRates, activeRegion, vendorName)}
variant="outline"
className="border-slate-300"
>
<Download className="w-4 h-4 mr-2" />
Export {activeRegion} Rates
</Button>
}
/>
{/* 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 Roles</p>
<p className="text-2xl font-bold text-[#1C323E]">{regionStats.totalRoles}</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">Avg Client Rate</p>
<p className="text-2xl font-bold text-[#1C323E]">{fmtCurrency(regionStats.avgClientRate)}</p>
</div>
<DollarSign 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">Avg Markup</p>
<p className="text-2xl font-bold text-[#1C323E]">{regionStats.avgMarkup.toFixed(1)}%</p>
</div>
<MapPin className="w-8 h-8 text-blue-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 VA Fee</p>
<p className="text-2xl font-bold text-[#1C323E]">{regionStats.avgVendorFee.toFixed(1)}%</p>
</div>
<DollarSign className="w-8 h-8 text-purple-600" />
</div>
</CardContent>
</Card>
</div>
{/* Region Tabs */}
<Card className="mb-6 border-slate-200">
<CardContent className="p-4">
<div className="flex flex-wrap gap-2">
{REGIONS.filter(region => ratesByRegion[region]?.length > 0).map(region => (
<button
key={region}
onClick={() => setActiveRegion(region)}
className={`px-6 py-3 rounded-lg text-sm font-medium transition-all ${
activeRegion === region
? "bg-[#0A39DF] text-white shadow-md"
: "bg-white text-slate-600 border border-slate-200 hover:border-[#0A39DF] hover:text-[#0A39DF]"
}`}
>
{region}
<Badge variant="secondary" className="ml-2 bg-slate-100 text-slate-700">
{ratesByRegion[region]?.length || 0}
</Badge>
</button>
))}
</div>
</CardContent>
</Card>
{/* Search Bar */}
<div className="mb-6">
<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 roles or categories..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-white border-slate-200"
/>
</div>
</div>
{/* Rates Table */}
<Card className="border-slate-200 shadow-sm">
<CardHeader className="border-b border-slate-100 bg-gradient-to-br from-slate-50 to-white">
<CardTitle className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<MapPin className="w-5 h-5 text-[#0A39DF]" />
{activeRegion} Rates
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{filteredRates.length > 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-6 font-semibold text-sm text-slate-700">Position</th>
<th className="text-left py-4 px-6 font-semibold text-sm text-slate-700">Category</th>
<th className="text-left py-4 px-6 font-semibold text-sm text-slate-700">Region</th>
<th className="text-right py-4 px-6 font-semibold text-sm text-slate-700">Employee Wage</th>
<th className="text-right py-4 px-6 font-semibold text-sm text-slate-700">Markup</th>
<th className="text-right py-4 px-6 font-semibold text-sm text-slate-700">VA Fee</th>
<th className="text-right py-4 px-6 font-semibold text-sm text-slate-700">Client Rate</th>
<th className="text-center py-4 px-6 font-semibold text-sm text-slate-700">Status</th>
</tr>
</thead>
<tbody>
{filteredRates.map((rate, idx) => {
const parsed = parseRoleName(rate.role_name);
return (
<tr
key={rate.id}
className={`border-b border-slate-100 hover:bg-slate-50 transition-colors ${
idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/30'
}`}
>
<td className="py-4 px-6">
<p className="font-semibold text-[#1C323E]">{parsed.position}</p>
</td>
<td className="py-4 px-6">
<Badge variant="outline" className="text-xs">
{rate.category}
</Badge>
</td>
<td className="py-4 px-6">
<p className="text-sm text-slate-600">{parsed.region || '—'}</p>
</td>
<td className="py-4 px-6 text-right">
<p className="font-medium text-slate-900">{fmtCurrency(rate.employee_wage)}</p>
<p className="text-xs text-slate-500">/hour</p>
</td>
<td className="py-4 px-6 text-right">
<p className="font-medium text-blue-600">{rate.markup_percentage}%</p>
<p className="text-xs text-slate-500">+{fmtCurrency(rate.employee_wage * (rate.markup_percentage / 100))}</p>
</td>
<td className="py-4 px-6 text-right">
<p className="font-medium text-purple-600">{rate.vendor_fee_percentage}%</p>
<p className="text-xs text-slate-500">+{fmtCurrency((rate.employee_wage * (1 + rate.markup_percentage / 100)) * (rate.vendor_fee_percentage / 100))}</p>
</td>
<td className="py-4 px-6 text-right">
<p className="text-2xl font-bold text-[#0A39DF]">{fmtCurrency(rate.client_rate)}</p>
<p className="text-xs text-slate-500">/hour</p>
</td>
<td className="py-4 px-6 text-center">
<div className="flex flex-col gap-1 items-center">
{rate.competitive_status && (
<Badge className="bg-green-100 text-green-700 text-xs">
Competitive
</Badge>
)}
{rate.minimum_wage_compliance && (
<Badge className="bg-emerald-100 text-emerald-700 text-xs">
Compliant
</Badge>
)}
{rate.csta_compliant && (
<Badge className="bg-blue-100 text-blue-700 text-xs">
CSTA
</Badge>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="text-center py-16">
<MapPin className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-900 mb-2">
{searchQuery ? "No matching rates found" : `No rates for ${activeRegion}`}
</h3>
<p className="text-slate-600">
{searchQuery ? "Try adjusting your search" : "Contact your account manager to set up rates for this region"}
</p>
</div>
)}
</CardContent>
</Card>
{/* Footer Info */}
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
<Building2 className="w-4 h-4 text-blue-600" />
</div>
<div>
<h4 className="font-semibold text-slate-900 mb-1">About Your Rates</h4>
<p className="text-sm text-slate-700">
These are your approved service rates for {activeRegion}. Rates include employee wages, markup, and vendor administration fees.
All rates are compliant with regional minimum wage requirements and have been approved by procurement.
</p>
</div>
</div>
</div>
</div>
</div>
);
}
// ==================== MAIN COMPONENT WITH ROLE-BASED ROUTING ====================
export default function VendorRates() {
const { data: user } = useQuery({
queryKey: ['current-user-vendor-rates'],
queryFn: () => base44.auth.me(),
});
const userRole = user?.user_role || user?.role || "admin";
const vendorName = user?.company_name || "Vendor";
const isVendor = userRole === "vendor";
// VENDOR ROLE: Show new company pricebook interface
if (isVendor) {
return <VendorCompanyPricebookView user={user} vendorName={vendorName} />;
}
// ALL OTHER ROLES: Show existing region-based rates view
return <AdminProcurementRatesView vendorName={vendorName} />;
}

152
src/pages/VendorStaff.jsx Normal file
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,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,124 @@
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";
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" />
{shift.date ? format(new Date(shift.date), 'PPP') : 'Date TBD'}
</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>
);
}

399
src/pages/index.jsx Normal file
View File

@@ -0,0 +1,399 @@
import Layout from "./Layout.jsx";
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 Home from "./Home";
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 { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
const PAGES = {
Dashboard: Dashboard,
StaffDirectory: StaffDirectory,
AddStaff: AddStaff,
EditStaff: EditStaff,
Events: Events,
CreateEvent: CreateEvent,
EditEvent: EditEvent,
EventDetail: EventDetail,
Business: Business,
Invoices: Invoices,
Payroll: Payroll,
Certification: Certification,
Support: Support,
Reports: Reports,
Settings: Settings,
ActivityLog: ActivityLog,
AddBusiness: AddBusiness,
EditBusiness: EditBusiness,
ProcurementDashboard: ProcurementDashboard,
OperatorDashboard: OperatorDashboard,
VendorDashboard: VendorDashboard,
WorkforceDashboard: WorkforceDashboard,
Messages: Messages,
ClientDashboard: ClientDashboard,
Onboarding: Onboarding,
ClientOrders: ClientOrders,
ClientInvoices: ClientInvoices,
VendorOrders: VendorOrders,
VendorStaff: VendorStaff,
VendorInvoices: VendorInvoices,
VendorPerformance: VendorPerformance,
WorkforceShifts: WorkforceShifts,
WorkforceEarnings: WorkforceEarnings,
WorkforceProfile: WorkforceProfile,
UserManagement: UserManagement,
Home: Home,
VendorRateCard: VendorRateCard,
Permissions: Permissions,
WorkforceCompliance: WorkforceCompliance,
Teams: Teams,
CreateTeam: CreateTeam,
TeamDetails: TeamDetails,
VendorManagement: VendorManagement,
PartnerManagement: PartnerManagement,
EnterpriseManagement: EnterpriseManagement,
VendorOnboarding: VendorOnboarding,
SectorManagement: SectorManagement,
AddEnterprise: AddEnterprise,
AddSector: AddSector,
AddPartner: AddPartner,
EditVendor: EditVendor,
SmartVendorOnboarding: SmartVendorOnboarding,
InviteVendor: InviteVendor,
VendorCompliance: VendorCompliance,
EditPartner: EditPartner,
EditSector: EditSector,
EditEnterprise: EditEnterprise,
VendorRates: VendorRates,
VendorDocumentReview: VendorDocumentReview,
}
function _getCurrentPage(url) {
if (url.endsWith('/')) {
url = url.slice(0, -1);
}
let urlLastPart = url.split('/').pop();
if (urlLastPart.includes('?')) {
urlLastPart = urlLastPart.split('?')[0];
}
const pageName = Object.keys(PAGES).find(page => page.toLowerCase() === urlLastPart.toLowerCase());
return pageName || Object.keys(PAGES)[0];
}
// Create a wrapper component that uses useLocation inside the Router context
function PagesContent() {
const location = useLocation();
const currentPage = _getCurrentPage(location.pathname);
return (
<Layout currentPageName={currentPage}>
<Routes>
<Route path="/" element={<Dashboard />} />
<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 />} />
</Routes>
</Layout>
);
}
export default function Pages() {
return (
<Router>
<PagesContent />
</Router>
);
}