feat: Initial commit of KROW Workforce Web client (Base44 export)
This commit is contained in:
154
src/pages/ActivityLog.jsx
Normal file
154
src/pages/ActivityLog.jsx
Normal 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
170
src/pages/AddBusiness.jsx
Normal 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
271
src/pages/AddEnterprise.jsx
Normal 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
319
src/pages/AddPartner.jsx
Normal 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
196
src/pages/AddSector.jsx
Normal 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
49
src/pages/AddStaff.jsx
Normal 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
343
src/pages/Business.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/pages/Certification.jsx
Normal file
19
src/pages/Certification.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
424
src/pages/ClientDashboard.jsx
Normal file
424
src/pages/ClientDashboard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
src/pages/ClientInvoices.jsx
Normal file
126
src/pages/ClientInvoices.jsx
Normal 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
394
src/pages/ClientOrders.jsx
Normal 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
121
src/pages/CreateEvent.jsx
Normal 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
258
src/pages/CreateTeam.jsx
Normal 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
261
src/pages/Dashboard.jsx
Normal 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
213
src/pages/EditBusiness.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
321
src/pages/EditEnterprise.jsx
Normal file
321
src/pages/EditEnterprise.jsx
Normal 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
79
src/pages/EditEvent.jsx
Normal 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
375
src/pages/EditPartner.jsx
Normal 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
239
src/pages/EditSector.jsx
Normal 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
79
src/pages/EditStaff.jsx
Normal 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
418
src/pages/EditVendor.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/pages/EnterpriseManagement.jsx
Normal file
131
src/pages/EnterpriseManagement.jsx
Normal 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
249
src/pages/EventDetail.jsx
Normal 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
561
src/pages/Events.jsx
Normal 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
55
src/pages/Home.jsx
Normal 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
380
src/pages/InviteVendor.jsx
Normal 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
430
src/pages/Invoices.jsx
Normal 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
543
src/pages/Layout.jsx
Normal 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
524
src/pages/Messages.jsx
Normal 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
513
src/pages/Onboarding.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
245
src/pages/OperatorDashboard.jsx
Normal file
245
src/pages/OperatorDashboard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
src/pages/PartnerManagement.jsx
Normal file
147
src/pages/PartnerManagement.jsx
Normal 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
19
src/pages/Payroll.jsx
Normal 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
677
src/pages/Permissions.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1920
src/pages/ProcurementDashboard.jsx
Normal file
1920
src/pages/ProcurementDashboard.jsx
Normal file
File diff suppressed because it is too large
Load Diff
1264
src/pages/Reports.jsx
Normal file
1264
src/pages/Reports.jsx
Normal file
File diff suppressed because it is too large
Load Diff
141
src/pages/SectorManagement.jsx
Normal file
141
src/pages/SectorManagement.jsx
Normal 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
19
src/pages/Settings.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2553
src/pages/SmartVendorOnboarding.jsx
Normal file
2553
src/pages/SmartVendorOnboarding.jsx
Normal file
File diff suppressed because it is too large
Load Diff
307
src/pages/StaffDirectory.jsx
Normal file
307
src/pages/StaffDirectory.jsx
Normal 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
19
src/pages/Support.jsx
Normal 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
873
src/pages/TeamDetails.jsx
Normal 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
1273
src/pages/Teams.jsx
Normal file
File diff suppressed because it is too large
Load Diff
375
src/pages/UserManagement.jsx
Normal file
375
src/pages/UserManagement.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1325
src/pages/VendorCompliance.jsx
Normal file
1325
src/pages/VendorCompliance.jsx
Normal file
File diff suppressed because it is too large
Load Diff
298
src/pages/VendorDashboard.jsx
Normal file
298
src/pages/VendorDashboard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
510
src/pages/VendorDocumentReview.jsx
Normal file
510
src/pages/VendorDocumentReview.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src/pages/VendorInvoices.jsx
Normal file
114
src/pages/VendorInvoices.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1147
src/pages/VendorManagement.jsx
Normal file
1147
src/pages/VendorManagement.jsx
Normal file
File diff suppressed because it is too large
Load Diff
752
src/pages/VendorOnboarding.jsx
Normal file
752
src/pages/VendorOnboarding.jsx
Normal 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: <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
809
src/pages/VendorOrders.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/pages/VendorPerformance.jsx
Normal file
112
src/pages/VendorPerformance.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
776
src/pages/VendorRateCard.jsx
Normal file
776
src/pages/VendorRateCard.jsx
Normal 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
644
src/pages/VendorRates.jsx
Normal 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> 2025–2028
|
||||
</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
152
src/pages/VendorStaff.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
src/pages/WorkforceCompliance.jsx
Normal file
169
src/pages/WorkforceCompliance.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
src/pages/WorkforceDashboard.jsx
Normal file
178
src/pages/WorkforceDashboard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/pages/WorkforceEarnings.jsx
Normal file
111
src/pages/WorkforceEarnings.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
255
src/pages/WorkforceProfile.jsx
Normal file
255
src/pages/WorkforceProfile.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/pages/WorkforceShifts.jsx
Normal file
124
src/pages/WorkforceShifts.jsx
Normal 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
399
src/pages/index.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user