new temporal folder to test
This commit is contained in:
167
frontend-web-free/src/pages/ActivityLog.jsx
Normal file
167
frontend-web-free/src/pages/ActivityLog.jsx
Normal file
@@ -0,0 +1,167 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Activity,
|
||||
Calendar,
|
||||
UserPlus,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Search,
|
||||
Filter
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import PageHeader from "../components/common/PageHeader";
|
||||
|
||||
const iconMap = {
|
||||
calendar: Calendar,
|
||||
user: UserPlus,
|
||||
invoice: FileText,
|
||||
message: MessageSquare,
|
||||
alert: AlertCircle,
|
||||
check: CheckCircle,
|
||||
};
|
||||
|
||||
const colorMap = {
|
||||
blue: "bg-blue-100 text-blue-600",
|
||||
red: "bg-red-100 text-red-600",
|
||||
green: "bg-green-100 text-green-600",
|
||||
yellow: "bg-yellow-100 text-yellow-600",
|
||||
purple: "bg-purple-100 text-purple-600",
|
||||
};
|
||||
|
||||
// Safe date formatter
|
||||
const safeFormatDistanceToNow = (dateString) => {
|
||||
if (!dateString) return "Unknown time";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
// Check for valid date object
|
||||
if (isNaN(date.getTime())) return "Unknown time";
|
||||
return formatDistanceToNow(date, { addSuffix: true });
|
||||
} catch {
|
||||
return "Unknown time";
|
||||
}
|
||||
};
|
||||
|
||||
export default function ActivityLog() {
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-activity'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: activities = [], isLoading } = useQuery({
|
||||
queryKey: ['activity-logs', user?.id],
|
||||
queryFn: () => base44.entities.ActivityLog.filter({ userId: user?.id }, '-created_date', 100),
|
||||
enabled: !!user?.id,
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const filteredActivities = activities.filter(activity => {
|
||||
const matchesSearch = !searchTerm ||
|
||||
activity.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
activity.description?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesTab = activeTab === "all" ||
|
||||
(activeTab === "unread" && !activity.is_read) ||
|
||||
(activeTab === "read" && activity.is_read);
|
||||
|
||||
return matchesSearch && matchesTab;
|
||||
});
|
||||
|
||||
const unreadCount = activities.filter(a => !a.is_read).length;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<PageHeader
|
||||
title="Activity Log"
|
||||
subtitle={`${activities.length} total activities • ${unreadCount} unread`}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 space-y-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="bg-white border border-slate-200">
|
||||
<TabsTrigger value="all">
|
||||
All <Badge variant="secondary" className="ml-2">{activities.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="unread">
|
||||
Unread <Badge variant="secondary" className="ml-2">{unreadCount}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="read">
|
||||
Read <Badge variant="secondary" className="ml-2">{activities.length - unreadCount}</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search activities..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activities List */}
|
||||
<div className="space-y-3">
|
||||
{filteredActivities.length > 0 ? (
|
||||
filteredActivities.map((activity) => {
|
||||
const Icon = iconMap[activity.icon_type] || AlertCircle;
|
||||
const colorClass = colorMap[activity.icon_color] || colorMap.blue;
|
||||
|
||||
return (
|
||||
<Card key={activity.id} className={`border-2 ${activity.is_read ? 'border-slate-200 opacity-70' : 'border-blue-200'}`}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex gap-4">
|
||||
<div className={`w-14 h-14 rounded-full ${colorClass} flex items-center justify-center flex-shrink-0`}>
|
||||
<Icon className="w-7 h-7" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="font-bold text-[#1C323E] text-lg">{activity.title}</h3>
|
||||
<span className="text-sm text-slate-500 whitespace-nowrap ml-4">
|
||||
{safeFormatDistanceToNow(activity.created_date)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-600 mb-4">{activity.description}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{!activity.is_read && (
|
||||
<Badge className="bg-blue-500 text-white">Unread</Badge>
|
||||
)}
|
||||
<Badge variant="outline">{activity.activity_type?.replace('_', ' ')}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Activity className="w-16 h-16 mx-auto text-slate-300 mb-4" />
|
||||
<h3 className="text-xl font-semibold text-slate-900 mb-2">No activities found</h3>
|
||||
<p className="text-slate-600">Try adjusting your filters</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
frontend-web-free/src/pages/AddBusiness.jsx
Normal file
170
frontend-web-free/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
frontend-web-free/src/pages/AddEnterprise.jsx
Normal file
271
frontend-web-free/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
frontend-web-free/src/pages/AddPartner.jsx
Normal file
319
frontend-web-free/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
frontend-web-free/src/pages/AddSector.jsx
Normal file
196
frontend-web-free/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
frontend-web-free/src/pages/AddStaff.jsx
Normal file
49
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
723
frontend-web-free/src/pages/Business.jsx
Normal file
723
frontend-web-free/src/pages/Business.jsx
Normal file
@@ -0,0 +1,723 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Plus, Building2, Mail, Phone, MapPin, Search, Eye, Trash2, ChevronDown, ChevronRight, Users, Star, Ban, Briefcase, Settings, UserPlus, UserMinus, Shield, Edit2, TrendingUp, Clock, Award, Grid3x3, List } from "lucide-react";
|
||||
import BusinessCard from "../components/business/BusinessCard";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import PageHeader from "../components/common/PageHeader";
|
||||
|
||||
import CreateBusinessModal from "../components/business/CreateBusinessModal";
|
||||
|
||||
export default function Business() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [filterManager, setFilterManager] = useState("all");
|
||||
const [filterHub, setFilterHub] = useState("all");
|
||||
const [filterGrade, setFilterGrade] = useState("all");
|
||||
const [filterCancelRate, setFilterCancelRate] = useState("all");
|
||||
const [viewMode, setViewMode] = useState("grid"); // grid or list
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-business'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: businesses, isLoading } = useQuery({
|
||||
queryKey: ['businesses'],
|
||||
queryFn: () => base44.entities.Business.list('-created_date'),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: allStaff = [] } = useQuery({
|
||||
queryKey: ['staff-for-business'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
});
|
||||
|
||||
const { data: allEvents = [] } = useQuery({
|
||||
queryKey: ['events-for-business-metrics'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: invoices = [] } = useQuery({
|
||||
queryKey: ['invoices-for-business'],
|
||||
queryFn: () => base44.entities.Invoice.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const userRole = user?.user_role || user?.role || "admin";
|
||||
const isVendor = userRole === "vendor";
|
||||
|
||||
const createBusinessMutation = useMutation({
|
||||
mutationFn: (businessData) => base44.entities.Business.create(businessData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['businesses'] });
|
||||
setCreateModalOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const updateBusinessMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Business.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['businesses'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateBusiness = (businessData) => {
|
||||
createBusinessMutation.mutate(businessData);
|
||||
};
|
||||
|
||||
const handleAddFavoriteStaff = async (businessId, staff) => {
|
||||
const business = businesses.find(b => b.id === businessId);
|
||||
const favoriteStaff = business.favorite_staff || [];
|
||||
const alreadyFavorite = favoriteStaff.some(s => s.staff_id === staff.id);
|
||||
|
||||
if (alreadyFavorite) return;
|
||||
|
||||
const updatedFavorites = [
|
||||
...favoriteStaff,
|
||||
{
|
||||
staff_id: staff.id,
|
||||
staff_name: staff.employee_name,
|
||||
position: staff.position,
|
||||
added_date: new Date().toISOString(),
|
||||
}
|
||||
];
|
||||
|
||||
await updateBusinessMutation.mutateAsync({
|
||||
id: businessId,
|
||||
data: {
|
||||
favorite_staff: updatedFavorites,
|
||||
favorite_staff_count: updatedFavorites.length,
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFavoriteStaff = async (businessId, staffId) => {
|
||||
const business = businesses.find(b => b.id === businessId);
|
||||
const favoriteStaff = business.favorite_staff || [];
|
||||
const updatedFavorites = favoriteStaff.filter(s => s.staff_id !== staffId);
|
||||
|
||||
await updateBusinessMutation.mutateAsync({
|
||||
id: businessId,
|
||||
data: {
|
||||
favorite_staff: updatedFavorites,
|
||||
favorite_staff_count: updatedFavorites.length,
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleBlockStaff = async (businessId, staff, reason) => {
|
||||
const business = businesses.find(b => b.id === businessId);
|
||||
const blockedStaff = business.blocked_staff || [];
|
||||
const alreadyBlocked = blockedStaff.some(s => s.staff_id === staff.id);
|
||||
|
||||
if (alreadyBlocked) return;
|
||||
|
||||
const updatedBlocked = [
|
||||
...blockedStaff,
|
||||
{
|
||||
staff_id: staff.id,
|
||||
staff_name: staff.employee_name,
|
||||
reason: reason || "No reason provided",
|
||||
blocked_date: new Date().toISOString(),
|
||||
}
|
||||
];
|
||||
|
||||
await updateBusinessMutation.mutateAsync({
|
||||
id: businessId,
|
||||
data: {
|
||||
blocked_staff: updatedBlocked,
|
||||
blocked_staff_count: updatedBlocked.length,
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnblockStaff = async (businessId, staffId) => {
|
||||
const business = businesses.find(b => b.id === businessId);
|
||||
const blockedStaff = business.blocked_staff || [];
|
||||
const updatedBlocked = blockedStaff.filter(s => s.staff_id !== staffId);
|
||||
|
||||
await updateBusinessMutation.mutateAsync({
|
||||
id: businessId,
|
||||
data: {
|
||||
blocked_staff: updatedBlocked,
|
||||
blocked_staff_count: updatedBlocked.length,
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Consolidate businesses by company name
|
||||
const consolidatedBusinesses = useMemo(() => {
|
||||
const grouped = {};
|
||||
|
||||
businesses.forEach(business => {
|
||||
// Extract company name (remove hub suffix if present)
|
||||
let companyName = business.business_name;
|
||||
|
||||
// Handle cases like "Google - BVG300" or "Nvidia - Building S"
|
||||
const dashIndex = companyName.indexOf(' - ');
|
||||
if (dashIndex > 0) {
|
||||
companyName = companyName.substring(0, dashIndex).trim();
|
||||
}
|
||||
|
||||
// Initialize company if not exists
|
||||
if (!grouped[companyName]) {
|
||||
grouped[companyName] = {
|
||||
company_name: companyName,
|
||||
hubs: [],
|
||||
primary_contact: business.contact_name,
|
||||
primary_email: business.email,
|
||||
primary_phone: business.phone,
|
||||
sector: business.notes?.includes('Sector:') ? business.notes.split('Sector:')[1].trim() : '',
|
||||
};
|
||||
}
|
||||
|
||||
// Add hub
|
||||
grouped[companyName].hubs.push({
|
||||
id: business.id,
|
||||
hub_name: business.business_name,
|
||||
contact_name: business.contact_name,
|
||||
email: business.email,
|
||||
phone: business.phone,
|
||||
address: business.address,
|
||||
city: business.city,
|
||||
notes: business.notes,
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(grouped);
|
||||
}, [businesses]);
|
||||
|
||||
// Get unique managers and hubs for filters
|
||||
const allManagers = [...new Set(consolidatedBusinesses.flatMap(c => c.hubs.map(h => h.contact_name).filter(Boolean)))];
|
||||
const allHubs = [...new Set(consolidatedBusinesses.flatMap(c => c.hubs.map(h => h.hub_name)))];
|
||||
|
||||
const filteredBusinesses = consolidatedBusinesses.filter(company => {
|
||||
const matchesSearch = !searchTerm ||
|
||||
company.company_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
company.hubs.some(hub =>
|
||||
hub.hub_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
hub.contact_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
hub.address?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const matchesManager = filterManager === "all" ||
|
||||
company.hubs.some(hub => hub.contact_name === filterManager);
|
||||
|
||||
const matchesHub = filterHub === "all" ||
|
||||
company.hubs.some(hub => hub.hub_name === filterHub);
|
||||
|
||||
// Calculate metrics for filtering
|
||||
const clientEvents = allEvents.filter(e =>
|
||||
e.business_name === company.company_name ||
|
||||
company.hubs.some(h => h.hub_name === e.business_name)
|
||||
);
|
||||
const totalOrders = clientEvents.length;
|
||||
const canceledOrders = clientEvents.filter(e => e.status === 'Canceled').length;
|
||||
const cancelationRate = totalOrders > 0 ? ((canceledOrders / totalOrders) * 100) : 0;
|
||||
|
||||
const totalStaffRequested = clientEvents.reduce((sum, e) => sum + (e.requested || 0), 0);
|
||||
const totalStaffAssigned = clientEvents.reduce((sum, e) => sum + (e.assigned_staff?.length || 0), 0);
|
||||
const fillRate = totalStaffRequested > 0 ? ((totalStaffAssigned / totalStaffRequested) * 100) : 0;
|
||||
|
||||
const onTimeOrders = clientEvents.filter(e => {
|
||||
const eventDate = new Date(e.date);
|
||||
const createdDate = new Date(e.created_date);
|
||||
const daysDiff = (eventDate - createdDate) / (1000 * 60 * 60 * 24);
|
||||
return daysDiff >= 3;
|
||||
}).length;
|
||||
const onTimeRate = totalOrders > 0 ? ((onTimeOrders / totalOrders) * 100) : 0;
|
||||
|
||||
const metrics = [
|
||||
parseFloat(fillRate),
|
||||
parseFloat(onTimeRate),
|
||||
100 - parseFloat(cancelationRate),
|
||||
Math.min((totalOrders / 10) * 100, 100)
|
||||
];
|
||||
const avgScore = metrics.reduce((a, b) => a + b, 0) / metrics.length;
|
||||
let clientGrade = 'C';
|
||||
if (avgScore >= 90) clientGrade = 'A+';
|
||||
else if (avgScore >= 85) clientGrade = 'A';
|
||||
else if (avgScore >= 80) clientGrade = 'A-';
|
||||
else if (avgScore >= 75) clientGrade = 'B+';
|
||||
else if (avgScore >= 70) clientGrade = 'B';
|
||||
|
||||
const matchesGrade = filterGrade === "all" || clientGrade === filterGrade;
|
||||
|
||||
const matchesCancelRate =
|
||||
filterCancelRate === "all" ||
|
||||
(filterCancelRate === "low" && cancelationRate < 5) ||
|
||||
(filterCancelRate === "medium" && cancelationRate >= 5 && cancelationRate < 15) ||
|
||||
(filterCancelRate === "high" && cancelationRate >= 15);
|
||||
|
||||
return matchesSearch && matchesManager && matchesHub && matchesGrade && matchesCancelRate;
|
||||
});
|
||||
|
||||
|
||||
|
||||
const canAddBusiness = ["admin", "procurement", "operator", "vendor"].includes(userRole);
|
||||
|
||||
const totalHubs = filteredBusinesses.reduce((sum, company) => sum + company.hubs.length, 0);
|
||||
|
||||
// Calculate KPIs
|
||||
const totalCompanies = filteredBusinesses.length;
|
||||
const goldClients = filteredBusinesses.filter(company => {
|
||||
const clientEvents = allEvents.filter(e =>
|
||||
e.business_name === company.company_name ||
|
||||
company.hubs.some(h => h.hub_name === e.business_name)
|
||||
);
|
||||
const totalOrders = clientEvents.length;
|
||||
const canceledOrders = clientEvents.filter(e => e.status === 'Canceled').length;
|
||||
const cancelationRate = totalOrders > 0 ? ((canceledOrders / totalOrders) * 100) : 0;
|
||||
const totalStaffRequested = clientEvents.reduce((sum, e) => sum + (e.requested || 0), 0);
|
||||
const totalStaffAssigned = clientEvents.reduce((sum, e) => sum + (e.assigned_staff?.length || 0), 0);
|
||||
const fillRate = totalStaffRequested > 0 ? ((totalStaffAssigned / totalStaffRequested) * 100) : 0;
|
||||
const onTimeOrders = clientEvents.filter(e => {
|
||||
const eventDate = new Date(e.date);
|
||||
const createdDate = new Date(e.created_date);
|
||||
const daysDiff = (eventDate - createdDate) / (1000 * 60 * 60 * 24);
|
||||
return daysDiff >= 3;
|
||||
}).length;
|
||||
const onTimeRate = totalOrders > 0 ? ((onTimeOrders / totalOrders) * 100) : 0;
|
||||
const metrics = [parseFloat(fillRate), parseFloat(onTimeRate), 100 - parseFloat(cancelationRate), Math.min((totalOrders / 10) * 100, 100)];
|
||||
const avgScore = metrics.reduce((a, b) => a + b, 0) / metrics.length;
|
||||
return avgScore >= 90;
|
||||
}).length;
|
||||
|
||||
const totalMonthlySpend = filteredBusinesses.reduce((sum, company) => {
|
||||
const clientInvoices = invoices.filter(i =>
|
||||
i.business_name === company.company_name ||
|
||||
company.hubs.some(h => h.hub_name === i.business_name)
|
||||
);
|
||||
const monthlySpend = clientInvoices
|
||||
.filter(i => {
|
||||
const invoiceDate = new Date(i.created_date);
|
||||
const now = new Date();
|
||||
return invoiceDate.getMonth() === now.getMonth() && invoiceDate.getFullYear() === now.getFullYear();
|
||||
})
|
||||
.reduce((s, i) => s + (i.amount || 0), 0);
|
||||
return sum + monthlySpend;
|
||||
}, 0);
|
||||
|
||||
const totalOrders = filteredBusinesses.reduce((sum, company) => {
|
||||
const clientEvents = allEvents.filter(e =>
|
||||
e.business_name === company.company_name ||
|
||||
company.hubs.some(h => h.hub_name === e.business_name)
|
||||
);
|
||||
return sum + clientEvents.length;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<PageHeader
|
||||
title="Business Directory"
|
||||
subtitle="Manage and monitor all business clients and their performance"
|
||||
actions={
|
||||
canAddBusiness ? (
|
||||
<Button
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white shadow-lg"
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Add Business
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI Dashboard */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card className="bg-gradient-to-br from-blue-600 to-blue-700 border-0 shadow-lg">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-blue-100 text-sm font-medium mb-1">Total Companies</p>
|
||||
<p className="text-4xl font-bold text-white">{totalCompanies}</p>
|
||||
</div>
|
||||
<div className="w-14 h-14 bg-white/20 rounded-xl flex items-center justify-center">
|
||||
<Building2 className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-amber-500 to-yellow-600 border-0 shadow-lg">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-amber-100 text-sm font-medium mb-1">Gold Clients</p>
|
||||
<p className="text-4xl font-bold text-white">{goldClients}</p>
|
||||
<p className="text-xs text-amber-100 mt-1">A+ Performance</p>
|
||||
</div>
|
||||
<div className="w-14 h-14 bg-white/20 rounded-xl flex items-center justify-center">
|
||||
<Award className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-green-600 to-emerald-700 border-0 shadow-lg">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-green-100 text-sm font-medium mb-1">Monthly Sales</p>
|
||||
<p className="text-4xl font-bold text-white">${(totalMonthlySpend / 1000).toFixed(0)}k</p>
|
||||
</div>
|
||||
<div className="w-14 h-14 bg-white/20 rounded-xl flex items-center justify-center">
|
||||
<TrendingUp className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-800 border-0 shadow-lg">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-100 text-sm font-medium mb-1">Total Orders</p>
|
||||
<p className="text-4xl font-bold text-white">{totalOrders}</p>
|
||||
<p className="text-xs text-slate-300 mt-1">{totalHubs} Hubs</p>
|
||||
</div>
|
||||
<div className="w-14 h-14 bg-white/20 rounded-xl flex items-center justify-center">
|
||||
<Briefcase className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters Section */}
|
||||
<div className="mb-6 space-y-4">
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search companies, hubs, contacts, or addresses..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-12 h-12 bg-white border-slate-300 text-base shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter Pills */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<select
|
||||
value={filterManager}
|
||||
onChange={(e) => setFilterManager(e.target.value)}
|
||||
className="px-4 py-2 bg-white border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Managers</option>
|
||||
{allManagers.map(manager => (
|
||||
<option key={manager} value={manager}>{manager}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterHub}
|
||||
onChange={(e) => setFilterHub(e.target.value)}
|
||||
className="px-4 py-2 bg-white border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Hubs</option>
|
||||
{allHubs.map(hub => (
|
||||
<option key={hub} value={hub}>{hub}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{isVendor && (
|
||||
<>
|
||||
<select
|
||||
value={filterGrade}
|
||||
onChange={(e) => setFilterGrade(e.target.value)}
|
||||
className="px-4 py-2 bg-white border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Grades</option>
|
||||
<option value="A+">A+ Grade</option>
|
||||
<option value="A">A Grade</option>
|
||||
<option value="A-">A- Grade</option>
|
||||
<option value="B+">B+ Grade</option>
|
||||
<option value="B">B Grade</option>
|
||||
<option value="C">C Grade</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterCancelRate}
|
||||
onChange={(e) => setFilterCancelRate(e.target.value)}
|
||||
className="px-4 py-2 bg-white border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Cancellation Rates</option>
|
||||
<option value="low">Low (<5%)</option>
|
||||
<option value="medium">Medium (5-15%)</option>
|
||||
<option value="high">High (>15%)</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(searchTerm || filterManager !== "all" || filterHub !== "all" || filterGrade !== "all" || filterCancelRate !== "all") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSearchTerm("");
|
||||
setFilterManager("all");
|
||||
setFilterHub("all");
|
||||
setFilterGrade("all");
|
||||
setFilterCancelRate("all");
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center gap-2 bg-white border border-slate-200 rounded-lg p-1">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("grid")}
|
||||
className={viewMode === "grid" ? "bg-blue-600 text-white" : "text-slate-600"}
|
||||
>
|
||||
<Grid3x3 className="w-4 h-4 mr-1" />
|
||||
Grid
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("list")}
|
||||
className={viewMode === "list" ? "bg-blue-600 text-white" : "text-slate-600"}
|
||||
>
|
||||
<List className="w-4 h-4 mr-1" />
|
||||
List
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Business List */}
|
||||
{filteredBusinesses.length > 0 ? (
|
||||
<div className={viewMode === "grid" ? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" : "space-y-4"}>
|
||||
{filteredBusinesses.map((company) => {
|
||||
// Get all businesses that belong to this company
|
||||
const companyBusinesses = businesses.filter(b =>
|
||||
b.business_name === company.company_name ||
|
||||
b.business_name.startsWith(company.company_name + ' - ')
|
||||
);
|
||||
|
||||
const firstBusiness = companyBusinesses[0];
|
||||
|
||||
// Collect team members from all businesses in this company
|
||||
const teamMembers = [];
|
||||
companyBusinesses.forEach(bus => {
|
||||
if (bus.team_members) {
|
||||
bus.team_members.forEach(member => {
|
||||
if (!teamMembers.some(m => m.email === member.email)) {
|
||||
teamMembers.push(member);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Extract hub managers from company.hubs (which contains hub info from consolidated structure)
|
||||
const hubContacts = company.hubs
|
||||
.filter(hub => hub.contact_name)
|
||||
.map(hub => ({
|
||||
member_id: `hub-${hub.hub_name}`,
|
||||
member_name: hub.contact_name,
|
||||
email: hub.email || '',
|
||||
phone: hub.phone,
|
||||
role: 'Hub Manager',
|
||||
title: 'Hub Manager',
|
||||
hub: hub.hub_name,
|
||||
is_active: true
|
||||
}));
|
||||
|
||||
// Combine team members and hub contacts, removing duplicates
|
||||
const allTeamMembers = [...teamMembers];
|
||||
hubContacts.forEach(hubContact => {
|
||||
const alreadyExists = allTeamMembers.some(m =>
|
||||
(hubContact.email && m.email === hubContact.email) ||
|
||||
(!hubContact.email && m.member_name === hubContact.member_name)
|
||||
);
|
||||
if (!alreadyExists) {
|
||||
allTeamMembers.push(hubContact);
|
||||
}
|
||||
});
|
||||
|
||||
const activeMembers = allTeamMembers.filter(m => m.is_active !== false).length;
|
||||
const totalMembers = allTeamMembers.length;
|
||||
|
||||
// Calculate client metrics for vendor view
|
||||
const clientEvents = allEvents.filter(e =>
|
||||
e.business_name === company.company_name ||
|
||||
company.hubs.some(h => h.hub_name === e.business_name)
|
||||
);
|
||||
|
||||
const totalOrders = clientEvents.length;
|
||||
const completedOrders = clientEvents.filter(e => e.status === 'Completed').length;
|
||||
const canceledOrders = clientEvents.filter(e => e.status === 'Canceled').length;
|
||||
const cancelationRate = totalOrders > 0 ? ((canceledOrders / totalOrders) * 100).toFixed(0) : 0;
|
||||
|
||||
const totalStaffRequested = clientEvents.reduce((sum, e) => sum + (e.requested || 0), 0);
|
||||
const totalStaffAssigned = clientEvents.reduce((sum, e) => sum + (e.assigned_staff?.length || 0), 0);
|
||||
const fillRate = totalStaffRequested > 0 ? ((totalStaffAssigned / totalStaffRequested) * 100).toFixed(0) : 0;
|
||||
|
||||
const clientInvoices = invoices.filter(i =>
|
||||
i.business_name === company.company_name ||
|
||||
company.hubs.some(h => h.hub_name === i.business_name)
|
||||
);
|
||||
const monthlySpend = clientInvoices
|
||||
.filter(i => {
|
||||
const invoiceDate = new Date(i.created_date);
|
||||
const now = new Date();
|
||||
return invoiceDate.getMonth() === now.getMonth() && invoiceDate.getFullYear() === now.getFullYear();
|
||||
})
|
||||
.reduce((sum, i) => sum + (i.amount || 0), 0);
|
||||
|
||||
const avgOrderValue = totalOrders > 0 ? clientInvoices.reduce((sum, i) => sum + (i.amount || 0), 0) / totalOrders : 0;
|
||||
|
||||
const onTimeOrders = clientEvents.filter(e => {
|
||||
const eventDate = new Date(e.date);
|
||||
const createdDate = new Date(e.created_date);
|
||||
const daysDiff = (eventDate - createdDate) / (1000 * 60 * 60 * 24);
|
||||
return daysDiff >= 3;
|
||||
}).length;
|
||||
const onTimeRate = totalOrders > 0 ? ((onTimeOrders / totalOrders) * 100).toFixed(0) : 0;
|
||||
|
||||
// Calculate grade based on metrics
|
||||
const getClientGrade = () => {
|
||||
const metrics = [
|
||||
parseFloat(fillRate),
|
||||
parseFloat(onTimeRate),
|
||||
100 - parseFloat(cancelationRate),
|
||||
Math.min((totalOrders / 10) * 100, 100)
|
||||
];
|
||||
const avgScore = metrics.reduce((a, b) => a + b, 0) / metrics.length;
|
||||
if (avgScore >= 90) return 'A+';
|
||||
if (avgScore >= 85) return 'A';
|
||||
if (avgScore >= 80) return 'A-';
|
||||
if (avgScore >= 75) return 'B+';
|
||||
if (avgScore >= 70) return 'B';
|
||||
return 'C';
|
||||
};
|
||||
|
||||
const clientGrade = getClientGrade();
|
||||
const gradeColor = clientGrade.startsWith('A') ? 'bg-green-500' : clientGrade.startsWith('B') ? 'bg-blue-500' : 'bg-orange-500';
|
||||
|
||||
// Calculate rapid orders (less than 3 days notice)
|
||||
const rapidOrders = clientEvents.filter(e => {
|
||||
const eventDate = new Date(e.date);
|
||||
const createdDate = new Date(e.created_date);
|
||||
const daysDiff = (eventDate - createdDate) / (1000 * 60 * 60 * 24);
|
||||
return daysDiff < 3;
|
||||
}).length;
|
||||
|
||||
// Get most requested position
|
||||
const roleCount = {};
|
||||
clientEvents.forEach(e => {
|
||||
if (e.shifts) {
|
||||
e.shifts.forEach(shift => {
|
||||
if (shift.roles) {
|
||||
shift.roles.forEach(role => {
|
||||
const roleName = role.role || 'Staff';
|
||||
roleCount[roleName] = (roleCount[roleName] || 0) + (role.count || 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
const mainPosition = Object.keys(roleCount).length > 0
|
||||
? Object.keys(roleCount).reduce((a, b) => roleCount[a] > roleCount[b] ? a : b)
|
||||
: 'Line Cook';
|
||||
|
||||
// Check if business is using KROW (based on having platform integration or active usage)
|
||||
const isUsingKROW = firstBusiness?.notes?.toLowerCase().includes('krow') ||
|
||||
totalOrders > 5 ||
|
||||
Math.random() > 0.5; // Randomize for demo - replace with actual logic
|
||||
|
||||
// Calculate last order date
|
||||
const lastOrderDate = clientEvents.length > 0
|
||||
? clientEvents.reduce((latest, event) => {
|
||||
const eventDate = new Date(event.date || event.created_date);
|
||||
return eventDate > latest ? eventDate : latest;
|
||||
}, new Date(0)).toISOString()
|
||||
: null;
|
||||
|
||||
const cardData = {
|
||||
companyName: company.company_name,
|
||||
logo: firstBusiness?.company_logo,
|
||||
sector: company.sector,
|
||||
monthlySpend: monthlySpend,
|
||||
totalStaff: totalStaffRequested,
|
||||
location: firstBusiness?.city || 'Bay Area',
|
||||
serviceType: firstBusiness?.service_specialty || 'Full Service Events',
|
||||
phone: company.primary_phone || '(555) 123-4567',
|
||||
email: company.primary_email || 'contact@company.com',
|
||||
technology: { isUsingKROW },
|
||||
performance: {
|
||||
fillRate: fillRate,
|
||||
onTimeRate: onTimeRate,
|
||||
cancelRate: cancelationRate,
|
||||
rapidOrders: rapidOrders,
|
||||
mainPosition: mainPosition
|
||||
},
|
||||
gradeColor: gradeColor,
|
||||
clientGrade: clientGrade,
|
||||
isActive: firstBusiness?.is_active,
|
||||
lastOrderDate: lastOrderDate,
|
||||
rateCard: firstBusiness?.rate_card,
|
||||
businessId: company.hubs[0]?.id
|
||||
};
|
||||
|
||||
return (
|
||||
<BusinessCard
|
||||
key={company.company_name}
|
||||
company={cardData}
|
||||
metrics={{ totalOrders, hubs: company.hubs.length, activeMembers }}
|
||||
isListView={viewMode === "list"}
|
||||
onView={() => navigate(createPageUrl(`EditBusiness?id=${company.hubs[0]?.id}`))}
|
||||
onEdit={() => navigate(createPageUrl(`EditBusiness?id=${company.hubs[0]?.id}`))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
|
||||
<Building2 className="w-16 h-16 mx-auto text-slate-300 mb-4" />
|
||||
<h3 className="text-xl font-semibold text-slate-900 mb-2">
|
||||
{searchTerm ? "No businesses found" : "No business clients yet"}
|
||||
</h3>
|
||||
<p className="text-slate-600 mb-6">
|
||||
{searchTerm ? "Try adjusting your search" : "Get started by adding your first business"}
|
||||
</p>
|
||||
{canAddBusiness && !searchTerm && (
|
||||
<Button
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add First Business
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Create Business Modal */}
|
||||
<CreateBusinessModal
|
||||
open={createModalOpen}
|
||||
onOpenChange={setCreateModalOpen}
|
||||
onSubmit={handleCreateBusiness}
|
||||
isSubmitting={createBusinessMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend-web-free/src/pages/Certification.jsx
Normal file
19
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
1273
frontend-web-free/src/pages/ClientDashboard.jsx
Normal file
1273
frontend-web-free/src/pages/ClientDashboard.jsx
Normal file
File diff suppressed because it is too large
Load Diff
126
frontend-web-free/src/pages/ClientInvoices.jsx
Normal file
126
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
860
frontend-web-free/src/pages/ClientOrders.jsx
Normal file
860
frontend-web-free/src/pages/ClientOrders.jsx
Normal file
@@ -0,0 +1,860 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Tabs, // New import
|
||||
TabsList, // New import
|
||||
TabsTrigger, // New import
|
||||
} from "@/components/ui/tabs"; // New import
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Search, Calendar, MapPin, Users, Eye, Edit, X, Trash2, FileText, // Edit instead of Edit2
|
||||
Clock, DollarSign, Package, CheckCircle, AlertTriangle, Grid, List, Zap, Plus, Building2, Bell, Edit3, Filter, CalendarIcon, Check, ChevronsUpDown
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { format, parseISO, isValid } from "date-fns";
|
||||
import OrderDetailModal from "../components/orders/OrderDetailModal";
|
||||
|
||||
const safeParseDate = (dateString) => {
|
||||
if (!dateString) return null;
|
||||
try {
|
||||
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
|
||||
return isValid(date) ? date : null;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
const safeFormatDate = (dateString, formatString) => {
|
||||
const date = safeParseDate(dateString);
|
||||
return date ? format(date, formatString) : '—';
|
||||
};
|
||||
|
||||
const convertTo12Hour = (time24) => {
|
||||
if (!time24) return "-";
|
||||
try {
|
||||
const [hours, minutes] = time24.split(':');
|
||||
const hour = parseInt(hours);
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = hour % 12 || 12;
|
||||
return `${hour12}:${minutes} ${ampm}`;
|
||||
} catch {
|
||||
return time24;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (event) => {
|
||||
if (event.is_rapid) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md">
|
||||
<Zap className="w-3.5 h-3.5 fill-white" />
|
||||
RAPID
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
'Draft': { bg: 'bg-slate-500', icon: FileText },
|
||||
'Pending': { bg: 'bg-amber-500', icon: Clock },
|
||||
'Partial Staffed': { bg: 'bg-orange-500', icon: AlertTriangle },
|
||||
'Fully Staffed': { bg: 'bg-emerald-500', icon: CheckCircle },
|
||||
'Active': { bg: 'bg-blue-500', icon: Users },
|
||||
'Completed': { bg: 'bg-slate-400', icon: CheckCircle },
|
||||
'Canceled': { bg: 'bg-red-500', icon: X },
|
||||
};
|
||||
|
||||
const config = statusConfig[event.status] || { bg: 'bg-slate-400', icon: Clock };
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-2 ${config.bg} text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{event.status}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ClientOrders() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all"); // Updated values for Tabs
|
||||
const [dateFilter, setDateFilter] = useState("all");
|
||||
const [specificDate, setSpecificDate] = useState(null);
|
||||
const [tempDate, setTempDate] = useState(null);
|
||||
const [locationFilter, setLocationFilter] = useState("all");
|
||||
const [managerFilter, setManagerFilter] = useState("all");
|
||||
const [locationOpen, setLocationOpen] = useState(false);
|
||||
const [managerOpen, setManagerOpen] = useState(false);
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false); // Changed from cancelDialog.open
|
||||
const [orderToCancel, setOrderToCancel] = useState(null); // Changed from cancelDialog.order
|
||||
const [viewOrderModal, setViewOrderModal] = useState(false);
|
||||
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||
const [calendarOpen, setCalendarOpen] = useState(false);
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-client-orders'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: allEvents = [] } = useQuery({
|
||||
queryKey: ['all-events-client'],
|
||||
queryFn: () => base44.entities.Event.list('-date'),
|
||||
});
|
||||
|
||||
const clientEvents = useMemo(() => {
|
||||
return allEvents.filter(e =>
|
||||
e.client_email === user?.email ||
|
||||
e.business_name === user?.company_name ||
|
||||
e.created_by === user?.email
|
||||
);
|
||||
}, [allEvents, user]);
|
||||
|
||||
const cancelOrderMutation = useMutation({
|
||||
mutationFn: (orderId) => base44.entities.Event.update(orderId, { status: "Canceled" }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['all-events-client'] });
|
||||
toast({
|
||||
title: "✅ Order Canceled",
|
||||
description: "Your order has been canceled successfully",
|
||||
});
|
||||
setCancelDialogOpen(false); // Updated
|
||||
setOrderToCancel(null); // Updated
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "❌ Failed to Cancel",
|
||||
description: "Could not cancel order. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Get unique locations and managers for filters
|
||||
const uniqueLocations = useMemo(() => {
|
||||
const locations = new Set();
|
||||
clientEvents.forEach(e => {
|
||||
if (e.hub) locations.add(e.hub);
|
||||
if (e.event_location) locations.add(e.event_location);
|
||||
});
|
||||
return Array.from(locations).sort();
|
||||
}, [clientEvents]);
|
||||
|
||||
const uniqueManagers = useMemo(() => {
|
||||
const managers = new Set();
|
||||
clientEvents.forEach(e => {
|
||||
if (e.manager_name) managers.add(e.manager_name);
|
||||
// Also check in shifts for manager names
|
||||
e.shifts?.forEach(shift => {
|
||||
if (shift.manager_name) managers.add(shift.manager_name);
|
||||
});
|
||||
});
|
||||
return Array.from(managers).sort();
|
||||
}, [clientEvents]);
|
||||
|
||||
const filteredOrders = useMemo(() => { // Renamed from filteredEvents
|
||||
let filtered = clientEvents;
|
||||
|
||||
if (searchTerm) {
|
||||
const lower = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(e =>
|
||||
e.event_name?.toLowerCase().includes(lower) ||
|
||||
e.business_name?.toLowerCase().includes(lower) ||
|
||||
e.hub?.toLowerCase().includes(lower) ||
|
||||
e.event_location?.toLowerCase().includes(lower) // Added event_location to search
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
// Reset time for comparison to only compare dates
|
||||
now.setHours(0, 0, 0, 0);
|
||||
|
||||
filtered = filtered.filter(e => {
|
||||
const eventDate = safeParseDate(e.date);
|
||||
const isCompleted = e.status === "Completed";
|
||||
const isCanceled = e.status === "Canceled";
|
||||
const isFutureOrPresent = eventDate && eventDate >= now;
|
||||
|
||||
if (statusFilter === "active") {
|
||||
return !isCompleted && !isCanceled && isFutureOrPresent;
|
||||
} else if (statusFilter === "completed") {
|
||||
return isCompleted;
|
||||
}
|
||||
return true; // For "all" or other statuses
|
||||
});
|
||||
|
||||
// Specific date filter (from calendar)
|
||||
if (specificDate) {
|
||||
filtered = filtered.filter(e => {
|
||||
const eventDate = safeParseDate(e.date);
|
||||
if (!eventDate) return false;
|
||||
const selectedDateNormalized = new Date(specificDate);
|
||||
selectedDateNormalized.setHours(0, 0, 0, 0);
|
||||
eventDate.setHours(0, 0, 0, 0);
|
||||
return eventDate.getTime() === selectedDateNormalized.getTime();
|
||||
});
|
||||
}
|
||||
// Date range filter
|
||||
else if (dateFilter !== "all") {
|
||||
filtered = filtered.filter(e => {
|
||||
const eventDate = safeParseDate(e.date);
|
||||
if (!eventDate) return false;
|
||||
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
|
||||
if (dateFilter === "today") {
|
||||
return eventDate.toDateString() === now.toDateString();
|
||||
} else if (dateFilter === "week") {
|
||||
const weekFromNow = new Date(now);
|
||||
weekFromNow.setDate(now.getDate() + 7);
|
||||
return eventDate >= now && eventDate <= weekFromNow;
|
||||
} else if (dateFilter === "month") {
|
||||
const monthFromNow = new Date(now);
|
||||
monthFromNow.setMonth(now.getMonth() + 1);
|
||||
return eventDate >= now && eventDate <= monthFromNow;
|
||||
} else if (dateFilter === "past") {
|
||||
return eventDate < now;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Location filter
|
||||
if (locationFilter !== "all") {
|
||||
filtered = filtered.filter(e =>
|
||||
e.hub === locationFilter || e.event_location === locationFilter
|
||||
);
|
||||
}
|
||||
|
||||
// Manager filter
|
||||
if (managerFilter !== "all") {
|
||||
filtered = filtered.filter(e => {
|
||||
if (e.manager_name === managerFilter) return true;
|
||||
// Check shifts for manager
|
||||
return e.shifts?.some(shift => shift.manager_name === managerFilter);
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [clientEvents, searchTerm, statusFilter, dateFilter, specificDate, locationFilter, managerFilter]);
|
||||
|
||||
const activeOrders = clientEvents.filter(e =>
|
||||
e.status !== "Completed" && e.status !== "Canceled"
|
||||
).length;
|
||||
const completedOrders = clientEvents.filter(e => e.status === "Completed").length;
|
||||
const totalSpent = clientEvents
|
||||
.filter(e => e.status === "Completed")
|
||||
.reduce((sum, e) => sum + (e.total || 0), 0);
|
||||
|
||||
const handleCancelOrder = (order) => {
|
||||
setOrderToCancel(order); // Updated
|
||||
setCancelDialogOpen(true); // Updated
|
||||
};
|
||||
|
||||
const handleViewOrder = (order) => {
|
||||
setSelectedOrder(order);
|
||||
setViewOrderModal(true);
|
||||
};
|
||||
|
||||
const confirmCancel = () => {
|
||||
if (orderToCancel) { // Updated
|
||||
cancelOrderMutation.mutate(orderToCancel.id); // Updated
|
||||
}
|
||||
};
|
||||
|
||||
const canEditOrder = (order) => {
|
||||
const eventDate = safeParseDate(order.date);
|
||||
const now = new Date();
|
||||
return order.status !== "Completed" &&
|
||||
order.status !== "Canceled" &&
|
||||
eventDate && eventDate > now; // Ensure eventDate is valid before comparison
|
||||
};
|
||||
|
||||
const canCancelOrder = (order) => {
|
||||
return order.status !== "Completed" && order.status !== "Canceled";
|
||||
};
|
||||
|
||||
const getAssignmentStatus = (event) => {
|
||||
const totalRequested = event.shifts?.reduce((accShift, shift) => {
|
||||
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
|
||||
}, 0) || 0;
|
||||
|
||||
const assigned = event.assigned_staff?.length || 0;
|
||||
const percentage = totalRequested > 0 ? Math.round((assigned / totalRequested) * 100) : 0;
|
||||
|
||||
let badgeClass = 'bg-slate-100 text-slate-600'; // Default: no staff, or no roles requested
|
||||
if (assigned > 0 && assigned < totalRequested) {
|
||||
badgeClass = 'bg-orange-500 text-white'; // Partial Staffed
|
||||
} else if (assigned >= totalRequested && totalRequested > 0) {
|
||||
badgeClass = 'bg-emerald-500 text-white'; // Fully Staffed
|
||||
} else if (assigned === 0 && totalRequested > 0) {
|
||||
badgeClass = 'bg-red-500 text-white'; // Requested but 0 assigned
|
||||
} else if (assigned > 0 && totalRequested === 0) {
|
||||
badgeClass = 'bg-blue-500 text-white'; // Staff assigned but no roles explicitly requested (e.g., event set up, staff assigned, but roles not detailed or count is 0)
|
||||
}
|
||||
|
||||
return {
|
||||
badgeClass,
|
||||
assigned,
|
||||
requested: totalRequested,
|
||||
percentage,
|
||||
};
|
||||
};
|
||||
|
||||
const getEventTimes = (event) => {
|
||||
const firstShift = event.shifts?.[0];
|
||||
const rolesInFirstShift = firstShift?.roles || [];
|
||||
|
||||
let startTime = null;
|
||||
let endTime = null;
|
||||
|
||||
if (rolesInFirstShift.length > 0) {
|
||||
startTime = rolesInFirstShift[0].start_time || null;
|
||||
endTime = rolesInFirstShift[0].end_time || null;
|
||||
}
|
||||
|
||||
return {
|
||||
startTime: startTime ? convertTo12Hour(startTime) : "-",
|
||||
endTime: endTime ? convertTo12Hour(endTime) : "-"
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-[1800px] mx-auto space-y-6">
|
||||
<div className=""> {/* Removed mb-6 */}
|
||||
<h1 className="text-2xl font-bold text-slate-900">My Orders</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">View and manage all your orders</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> {/* Removed mb-6 from here as it's now part of space-y-6 */}
|
||||
<Card className="border border-blue-200 bg-blue-50">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<Package className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-blue-600 font-semibold uppercase">TOTAL</p>
|
||||
<p className="text-2xl font-bold text-blue-700">{clientEvents.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-orange-200 bg-orange-50">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center">
|
||||
<Clock className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-orange-600 font-semibold uppercase">ACTIVE</p>
|
||||
<p className="text-2xl font-bold text-orange-700">{activeOrders}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-green-200 bg-green-50">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center">
|
||||
<CheckCircle className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-green-600 font-semibold uppercase">COMPLETED</p>
|
||||
<p className="text-2xl font-bold text-green-700">{completedOrders}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-purple-200 bg-purple-50">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-500 rounded-lg flex items-center justify-center">
|
||||
<DollarSign className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-purple-600 font-semibold uppercase">TOTAL SPENT</p>
|
||||
<p className="text-2xl font-bold text-purple-700">${Math.round(totalSpent / 1000)}k</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 border shadow-sm">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search orders..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 border-slate-300 h-10"
|
||||
/>
|
||||
</div>
|
||||
<Tabs value={statusFilter} onValueChange={setStatusFilter} className="w-fit">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="active">Active</TabsTrigger>
|
||||
<TabsTrigger value="completed">Completed</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-sm font-medium text-slate-700">Filters:</span>
|
||||
</div>
|
||||
|
||||
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`h-9 w-[160px] justify-start text-left font-normal ${specificDate ? 'bg-blue-50 border-blue-300' : ''}`}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{specificDate ? format(specificDate, 'MMM dd, yyyy') : 'Pick a date'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div className="p-6">
|
||||
<CalendarComponent
|
||||
mode="single"
|
||||
selected={tempDate || specificDate}
|
||||
onSelect={(date) => setTempDate(date)}
|
||||
numberOfMonths={2}
|
||||
initialFocus
|
||||
/>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-slate-200">
|
||||
<div className="flex items-center justify-center gap-3 mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTempDate(new Date())}
|
||||
className={`px-6 h-10 font-medium ${!tempDate && !specificDate ? 'border-b-2 border-blue-600 rounded-none' : ''}`}
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
setTempDate(yesterday);
|
||||
}}
|
||||
className="px-6 h-10 font-medium"
|
||||
>
|
||||
Yesterday
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
setTempDate(tomorrow);
|
||||
}}
|
||||
className="px-6 h-10 font-medium"
|
||||
>
|
||||
Tomorrow
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTempDate(null);
|
||||
setDateFilter("week");
|
||||
}}
|
||||
className="px-6 h-10 font-medium"
|
||||
>
|
||||
This Week
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTempDate(null);
|
||||
setDateFilter("month");
|
||||
}}
|
||||
className="px-6 h-10 font-medium"
|
||||
>
|
||||
This Month
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTempDate(null);
|
||||
setSpecificDate(null);
|
||||
setDateFilter("all");
|
||||
setCalendarOpen(false);
|
||||
}}
|
||||
className="px-8 h-10 text-red-600 border-red-300 hover:bg-red-50 font-medium"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTempDate(null);
|
||||
setCalendarOpen(false);
|
||||
}}
|
||||
className="px-8 h-10 font-medium"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (tempDate) {
|
||||
setSpecificDate(tempDate);
|
||||
setDateFilter("all");
|
||||
}
|
||||
setTempDate(null);
|
||||
setCalendarOpen(false);
|
||||
}}
|
||||
className="px-10 h-10 bg-blue-600 hover:bg-blue-700 font-medium"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover open={locationOpen} onOpenChange={setLocationOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={locationOpen}
|
||||
className="w-[200px] h-9 justify-between text-sm"
|
||||
>
|
||||
<span className="truncate">{locationFilter === "all" ? "All Locations" : locationFilter}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command shouldFilter={true}>
|
||||
<CommandInput placeholder="Type to search..." className="h-9" />
|
||||
<CommandEmpty>No location found.</CommandEmpty>
|
||||
<CommandGroup className="max-h-64 overflow-auto">
|
||||
<CommandItem
|
||||
value="all"
|
||||
onSelect={() => {
|
||||
setLocationFilter("all");
|
||||
setLocationOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={`mr-2 h-4 w-4 ${locationFilter === "all" ? "opacity-100" : "opacity-0"}`} />
|
||||
All Locations
|
||||
</CommandItem>
|
||||
{uniqueLocations.map((location) => (
|
||||
<CommandItem
|
||||
key={location}
|
||||
value={location}
|
||||
onSelect={(currentValue) => {
|
||||
setLocationFilter(currentValue);
|
||||
setLocationOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={`mr-2 h-4 w-4 ${locationFilter === location ? "opacity-100" : "opacity-0"}`} />
|
||||
{location}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover open={managerOpen} onOpenChange={setManagerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={managerOpen}
|
||||
className="w-[200px] h-9 justify-between text-sm"
|
||||
>
|
||||
<span className="truncate">{managerFilter === "all" ? "All Managers" : managerFilter}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command shouldFilter={true}>
|
||||
<CommandInput placeholder="Type to search..." className="h-9" />
|
||||
<CommandEmpty>No manager found.</CommandEmpty>
|
||||
<CommandGroup className="max-h-64 overflow-auto">
|
||||
<CommandItem
|
||||
value="all"
|
||||
onSelect={() => {
|
||||
setManagerFilter("all");
|
||||
setManagerOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={`mr-2 h-4 w-4 ${managerFilter === "all" ? "opacity-100" : "opacity-0"}`} />
|
||||
All Managers
|
||||
</CommandItem>
|
||||
{uniqueManagers.map((manager) => (
|
||||
<CommandItem
|
||||
key={manager}
|
||||
value={manager}
|
||||
onSelect={(currentValue) => {
|
||||
setManagerFilter(currentValue);
|
||||
setManagerOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={`mr-2 h-4 w-4 ${managerFilter === manager ? "opacity-100" : "opacity-0"}`} />
|
||||
{manager}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{(dateFilter !== "all" || specificDate || locationFilter !== "all" || managerFilter !== "all") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDateFilter("all");
|
||||
setSpecificDate(null);
|
||||
setLocationFilter("all");
|
||||
setManagerFilter("all");
|
||||
}}
|
||||
className="text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-slate-200 shadow-sm"> {/* Card class updated */}
|
||||
<CardContent className="p-0"> {/* CardContent padding updated */}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 hover:bg-slate-50">
|
||||
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Business</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Hub</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Event</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Date & Time</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Status</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Requested</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Assigned</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Invoice</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-xs uppercase text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredOrders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-12 text-slate-500">
|
||||
<Package className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
||||
<p className="font-medium">No orders found</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredOrders.map((order) => {
|
||||
const assignedCount = order.assigned_staff?.length || 0;
|
||||
const requestedCount = order.requested || 0;
|
||||
const assignmentProgress = requestedCount > 0 ? Math.round((assignedCount / requestedCount) * 100) : 0;
|
||||
const { startTime, endTime } = getEventTimes(order);
|
||||
|
||||
return (
|
||||
<TableRow key={order.id} className="hover:bg-slate-50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-slate-900">{order.business_name || "Primary Location"}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-purple-600" />
|
||||
<span className="text-sm text-slate-700">{order.hub || "Main Hub"}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="font-semibold text-slate-900">{order.event_name || "Untitled Event"}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{safeFormatDate(order.date, 'MM.dd.yyyy')}</p>
|
||||
<p className="text-xs text-slate-500 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{startTime} - {endTime}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(order)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-lg font-bold text-slate-900">{requestedCount}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="w-10 h-10 bg-emerald-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">{assignedCount}</span>
|
||||
</div>
|
||||
<span className="text-xs text-emerald-600 font-semibold">{assignmentProgress}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button className="w-8 h-8 flex items-center justify-center hover:bg-slate-100 rounded transition-colors">
|
||||
<FileText className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleViewOrder(order)}
|
||||
className="h-8 w-8 p-0"
|
||||
title="View"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 p-0"
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
</Button>
|
||||
{canEditOrder(order) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(createPageUrl(`EditEvent?id=${order.id}`))}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canCancelOrder(order) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleCancelOrder(order)}
|
||||
className="h-8 w-8 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<OrderDetailModal
|
||||
open={viewOrderModal}
|
||||
onClose={() => setViewOrderModal(false)}
|
||||
order={selectedOrder}
|
||||
onCancel={handleCancelOrder}
|
||||
/>
|
||||
|
||||
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}> {/* Updated open and onOpenChange */}
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Cancel Order?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to cancel this order? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{orderToCancel && ( // Using orderToCancel
|
||||
<div className="bg-slate-50 rounded-lg p-4 space-y-2">
|
||||
<p className="font-bold text-slate-900">{orderToCancel.event_name}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{orderToCancel.date ? format(new Date(orderToCancel.date), "MMMM d, yyyy") : "—"}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{orderToCancel.hub || orderToCancel.event_location}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCancelDialogOpen(false)} // Updated
|
||||
>
|
||||
Keep Order
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmCancel}
|
||||
disabled={cancelOrderMutation.isPending}
|
||||
>
|
||||
{cancelOrderMutation.isPending ? "Canceling..." : "Yes, Cancel Order"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
frontend-web-free/src/pages/CreateEvent.jsx
Normal file
191
frontend-web-free/src/pages/CreateEvent.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import EventFormWizard from "../components/events/EventFormWizard";
|
||||
import RapidOrderInterface from "../components/orders/RapidOrderInterface";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, AlertTriangle } from "lucide-react";
|
||||
import { detectAllConflicts, ConflictAlert } from "../components/scheduling/ConflictDetection";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default function CreateEvent() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [pendingEvent, setPendingEvent] = React.useState(null);
|
||||
const [showConflictWarning, setShowConflictWarning] = React.useState(false);
|
||||
const [showRapidInterface, setShowRapidInterface] = React.useState(false);
|
||||
|
||||
const { data: currentUser } = useQuery({
|
||||
queryKey: ['current-user-create-event'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: allEvents = [] } = useQuery({
|
||||
queryKey: ['events-for-conflict-check'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const createEventMutation = useMutation({
|
||||
mutationFn: (eventData) => base44.entities.Event.create(eventData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['client-events'] });
|
||||
toast({
|
||||
title: "✅ Event Created",
|
||||
description: "Your event has been created successfully.",
|
||||
});
|
||||
navigate(createPageUrl("Events"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "❌ Failed to Create Event",
|
||||
description: error.message || "There was an error creating the event.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleRapidSubmit = (rapidData) => {
|
||||
// Convert rapid order message to event data
|
||||
const eventData = {
|
||||
event_name: "RAPID Order",
|
||||
order_type: "rapid",
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
status: "Active",
|
||||
notes: rapidData.rawMessage,
|
||||
shifts: [{
|
||||
shift_name: "Shift 1",
|
||||
location_address: "",
|
||||
same_as_billing: true,
|
||||
roles: [{
|
||||
role: "",
|
||||
department: "",
|
||||
count: 1,
|
||||
start_time: "09:00",
|
||||
end_time: "17:00",
|
||||
hours: 8,
|
||||
uniform: "Type 1",
|
||||
break_minutes: 15,
|
||||
rate_per_hour: 0,
|
||||
total_value: 0
|
||||
}]
|
||||
}],
|
||||
requested: 1
|
||||
};
|
||||
|
||||
createEventMutation.mutate(eventData);
|
||||
};
|
||||
|
||||
const handleSubmit = (eventData) => {
|
||||
// CRITICAL: Calculate total requested count from all roles before creating
|
||||
const totalRequested = eventData.shifts.reduce((sum, shift) => {
|
||||
return sum + shift.roles.reduce((roleSum, role) => roleSum + (parseInt(role.count) || 0), 0);
|
||||
}, 0);
|
||||
|
||||
const eventDataWithRequested = {
|
||||
...eventData,
|
||||
requested: totalRequested // Set exact requested count
|
||||
};
|
||||
|
||||
// Detect conflicts before creating
|
||||
const conflicts = detectAllConflicts(eventDataWithRequested, allEvents);
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
setPendingEvent({ ...eventDataWithRequested, detected_conflicts: conflicts });
|
||||
setShowConflictWarning(true);
|
||||
} else {
|
||||
createEventMutation.mutate(eventDataWithRequested);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmWithConflicts = () => {
|
||||
if (pendingEvent) {
|
||||
createEventMutation.mutate(pendingEvent);
|
||||
setShowConflictWarning(false);
|
||||
setPendingEvent(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelConflicts = () => {
|
||||
setShowConflictWarning(false);
|
||||
setPendingEvent(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<div className="max-w-7xl mx-auto p-4 md:p-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-[#1C323E]">Create Standard Order</h1>
|
||||
<p className="text-slate-600 mt-1">
|
||||
Fill out the details for your planned event
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate(createPageUrl("ClientDashboard"))}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Conflict Warning Modal */}
|
||||
{showConflictWarning && pendingEvent && (
|
||||
<Card className="mb-6 border-2 border-orange-500">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<AlertTriangle className="w-6 h-6 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-slate-900 mb-1">
|
||||
Scheduling Conflicts Detected
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
This event has {pendingEvent.detected_conflicts.length} potential conflict{pendingEvent.detected_conflicts.length !== 1 ? 's' : ''}
|
||||
with existing bookings. Review the conflicts below and decide how to proceed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<ConflictAlert conflicts={pendingEvent.detected_conflicts} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancelConflicts}
|
||||
>
|
||||
Go Back & Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmWithConflicts}
|
||||
className="bg-orange-600 hover:bg-orange-700"
|
||||
>
|
||||
Create Anyway
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<EventFormWizard
|
||||
event={null}
|
||||
onSubmit={handleSubmit}
|
||||
onRapidSubmit={handleRapidSubmit}
|
||||
isSubmitting={createEventMutation.isPending}
|
||||
currentUser={currentUser}
|
||||
onCancel={() => navigate(createPageUrl("ClientDashboard"))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
258
frontend-web-free/src/pages/CreateTeam.jsx
Normal file
258
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
488
frontend-web-free/src/pages/Dashboard.jsx
Normal file
488
frontend-web-free/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,488 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Users, Building2, UserPlus, TrendingUp, MapPin, Calendar, DollarSign, Award, Target, BarChart3, Shield, Leaf, Eye, Edit, Sparkles, Zap, Clock, AlertTriangle, CheckCircle, FileText, X } from "lucide-react";
|
||||
import StatsCard from "../components/staff/StatsCard";
|
||||
import EcosystemWheel from "../components/dashboard/EcosystemWheel";
|
||||
import QuickMetrics from "../components/dashboard/QuickMetrics";
|
||||
import PageHeader from "../components/common/PageHeader";
|
||||
import { format, parseISO, isValid, isSameDay, startOfDay } from "date-fns";
|
||||
|
||||
const safeParseDate = (dateString) => {
|
||||
if (!dateString) return null;
|
||||
try {
|
||||
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
|
||||
return isValid(date) ? date : null;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
const safeFormatDate = (dateString, formatStr) => {
|
||||
const date = safeParseDate(dateString);
|
||||
if (!date) return "-";
|
||||
try { return format(date, formatStr); } catch { return "-"; }
|
||||
};
|
||||
|
||||
const convertTo12Hour = (time24) => {
|
||||
if (!time24) return "-";
|
||||
try {
|
||||
const [hours, minutes] = time24.split(':');
|
||||
const hour = parseInt(hours);
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = hour % 12 || 12;
|
||||
return `${hour12}:${minutes} ${ampm}`;
|
||||
} catch {
|
||||
return time24;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (event) => {
|
||||
if (event.is_rapid) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md">
|
||||
<Zap className="w-3.5 h-3.5 fill-white" />
|
||||
RAPID
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
'Draft': { bg: 'bg-slate-500', icon: FileText },
|
||||
'Pending': { bg: 'bg-amber-500', icon: Clock },
|
||||
'Partial Staffed': { bg: 'bg-orange-500', icon: AlertTriangle },
|
||||
'Fully Staffed': { bg: 'bg-emerald-500', icon: CheckCircle },
|
||||
'Active': { bg: 'bg-blue-500', icon: Users },
|
||||
'Completed': { bg: 'bg-slate-400', icon: CheckCircle },
|
||||
'Canceled': { bg: 'bg-red-500', icon: X },
|
||||
};
|
||||
|
||||
const config = statusConfig[event.status] || { bg: 'bg-slate-400', icon: Clock };
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-2 ${config.bg} text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{event.status}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getEventTimes = (event) => {
|
||||
const firstShift = event.shifts?.[0];
|
||||
const rolesInFirstShift = firstShift?.roles || [];
|
||||
|
||||
let startTime = null;
|
||||
let endTime = null;
|
||||
|
||||
if (rolesInFirstShift.length > 0) {
|
||||
startTime = rolesInFirstShift[0].start_time || null;
|
||||
endTime = rolesInFirstShift[0].end_time || null;
|
||||
}
|
||||
|
||||
return {
|
||||
startTime: startTime ? convertTo12Hour(startTime) : "-",
|
||||
endTime: endTime ? convertTo12Hour(endTime) : "-"
|
||||
};
|
||||
};
|
||||
|
||||
const getAssignmentStatus = (event) => {
|
||||
const totalRequested = event.shifts?.reduce((accShift, shift) => {
|
||||
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
|
||||
}, 0) || 0;
|
||||
|
||||
const assigned = event.assigned_staff?.length || 0;
|
||||
const fillPercent = totalRequested > 0 ? Math.round((assigned / totalRequested) * 100) : 0;
|
||||
|
||||
if (assigned === 0) return { color: 'bg-slate-200 text-slate-600', text: '0', percent: '0%', status: 'empty' };
|
||||
if (totalRequested > 0 && assigned >= totalRequested) return { color: 'bg-emerald-500 text-white', text: assigned, percent: '100%', status: 'full' };
|
||||
if (totalRequested > 0 && assigned < totalRequested) return { color: 'bg-slate-200 text-slate-600', text: assigned, percent: `${fillPercent}%`, status: 'partial' };
|
||||
return { color: 'bg-slate-200 text-slate-600', text: assigned, percent: '0%', status: 'partial' };
|
||||
};
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedLayer, setSelectedLayer] = useState(null);
|
||||
|
||||
const { data: staff, isLoading: loadingStaff } = useQuery({
|
||||
queryKey: ['staff'],
|
||||
queryFn: () => base44.entities.Staff.list('-created_date'),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: events, isLoading: loadingEvents } = useQuery({
|
||||
queryKey: ['events'],
|
||||
queryFn: () => base44.entities.Event.list('-date'),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
// Filter events for today only
|
||||
const today = startOfDay(new Date());
|
||||
const todaysEvents = events.filter(event => {
|
||||
const eventDate = safeParseDate(event.date);
|
||||
return eventDate && isSameDay(eventDate, today);
|
||||
});
|
||||
|
||||
const recentStaff = staff.slice(0, 6);
|
||||
const uniqueDepartments = [...new Set(staff.map(s => s.department).filter(Boolean))];
|
||||
const uniqueLocations = [...new Set(staff.map(s => s.hub_location).filter(Boolean))];
|
||||
|
||||
// Calculate key metrics
|
||||
const totalFillRate = 97;
|
||||
const totalSpend = 2.3;
|
||||
const overallScore = "A+";
|
||||
const activeEvents = events.filter(e => e.status === "Active" || e.status === "Confirmed").length;
|
||||
const completionRate = events.length > 0 ? Math.round((events.filter(e => e.status === "Completed").length / events.length) * 100) : 0;
|
||||
|
||||
const ecosystemLayers = [
|
||||
{
|
||||
name: "Buyer(Procurements)",
|
||||
icon: DollarSign,
|
||||
color: "from-[#0A39DF] to-[#1C323E]",
|
||||
metrics: { fillRate: "97%", spend: "$2.3M", score: "A+" },
|
||||
route: "ProcurementDashboard"
|
||||
},
|
||||
{
|
||||
name: "Enterprises (Operator)",
|
||||
icon: Target,
|
||||
color: "from-emerald-500 to-emerald-700",
|
||||
metrics: { coverage: "94%", incidents: "2", satisfaction: "4.8/5" },
|
||||
route: "OperatorDashboard"
|
||||
},
|
||||
{
|
||||
name: "Sectors (Execution)",
|
||||
icon: Building2,
|
||||
color: "from-purple-500 to-purple-700",
|
||||
metrics: { active: activeEvents, revenue: "$1.8M", growth: "+12%" },
|
||||
route: "OperatorDashboard"
|
||||
},
|
||||
{
|
||||
name: "Partner",
|
||||
icon: Users,
|
||||
color: "from-pink-500 to-pink-700",
|
||||
metrics: { total: "45", retention: "92%", nps: "8.5" },
|
||||
route: "Business"
|
||||
},
|
||||
{
|
||||
name: "Approved Vendor",
|
||||
icon: Award,
|
||||
color: "from-amber-500 to-amber-700",
|
||||
metrics: { partners: "12", rating: "4.7/5", compliance: "98%" },
|
||||
route: "VendorDashboard"
|
||||
},
|
||||
{
|
||||
name: "Workforce",
|
||||
icon: UserPlus,
|
||||
color: "from-[#0A39DF] to-[#0A39DF]/80",
|
||||
metrics: { total: staff.length, active: staff.filter(s => s.check_in).length, trained: "89%" },
|
||||
route: "WorkforceDashboard"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-gradient-to-br from-slate-50 to-slate-100 min-h-screen">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<PageHeader
|
||||
title="Welcome to KROW"
|
||||
subtitle="Your Complete Workforce Management Ecosystem"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="border-slate-300 hover:bg-slate-100">
|
||||
<BarChart3 className="w-4 h-4 mr-2" />
|
||||
Reports
|
||||
</Button>
|
||||
<Link to={createPageUrl("Events")}>
|
||||
<Button className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg">
|
||||
<Calendar className="w-5 h-5 mr-2" />
|
||||
View All Orders
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Global Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<StatsCard
|
||||
title="Fill Rate"
|
||||
value={`${totalFillRate}%`}
|
||||
icon={Target}
|
||||
gradient="bg-gradient-to-br from-[#0A39DF] to-[#1C323E]"
|
||||
change="+2.5% this month"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Total Spend"
|
||||
value={`$${totalSpend}M`}
|
||||
icon={DollarSign}
|
||||
gradient="bg-gradient-to-br from-emerald-500 to-emerald-700"
|
||||
change="+$180K this month"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Overall Score"
|
||||
value={overallScore}
|
||||
icon={Award}
|
||||
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Active Events"
|
||||
value={activeEvents}
|
||||
icon={Calendar}
|
||||
gradient="bg-gradient-to-br from-purple-500 to-purple-700"
|
||||
change={`${completionRate}% completion rate`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Today's Orders Section */}
|
||||
<Card className="mb-8 border-slate-200 shadow-lg">
|
||||
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-[#1C323E] flex items-center gap-2">
|
||||
<Calendar className="w-6 h-6 text-[#0A39DF]" />
|
||||
Today's Orders - {format(today, 'EEEE, MMMM d, yyyy')}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-slate-500 mt-1">Orders scheduled for today only</p>
|
||||
</div>
|
||||
<Link to={createPageUrl("Events")}>
|
||||
<Button variant="outline" className="border-slate-300">
|
||||
View All Orders
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{todaysEvents.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Calendar className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
||||
<p className="font-medium">No orders scheduled for today</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 hover:bg-slate-50 border-b">
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide h-10">BUSINESS</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">HUB</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">EVENT</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">DATE & TIME</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">STATUS</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">REQUESTED</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ASSIGNED</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ACTIONS</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{todaysEvents.map((event) => {
|
||||
const assignmentStatus = getAssignmentStatus(event);
|
||||
const eventTimes = getEventTimes(event);
|
||||
const eventDate = safeParseDate(event.date);
|
||||
const dayOfWeek = eventDate ? format(eventDate, 'EEEE') : '';
|
||||
|
||||
return (
|
||||
<TableRow key={event.id} className="hover:bg-slate-50 transition-colors border-b">
|
||||
<TableCell className="py-3">
|
||||
<p className="text-sm text-slate-700 font-medium">{event.business_name || "—"}</p>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<div className="flex items-center gap-1.5 text-sm text-slate-500">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
{event.hub || event.event_location || "Main Hub"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<p className="font-semibold text-slate-900 text-sm">{event.event_name}</p>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm text-slate-900 font-semibold">{eventDate ? format(eventDate, 'MM.dd.yyyy') : '-'}</p>
|
||||
<p className="text-xs text-slate-500">{dayOfWeek}</p>
|
||||
<div className="flex items-center gap-1 text-xs text-slate-600 mt-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{eventTimes.startTime} - {eventTimes.endTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
{getStatusBadge(event)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-3">
|
||||
<span className="font-semibold text-slate-700 text-sm">{event.requested || 0}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-3">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className={`w-10 h-10 rounded-full ${assignmentStatus.color} flex items-center justify-center font-bold text-sm`}>
|
||||
{assignmentStatus.text}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-medium">{assignmentStatus.percent}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(createPageUrl(`EventDetail?id=${event.id}`))}
|
||||
className="hover:bg-slate-100 h-8 w-8"
|
||||
title="View"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
|
||||
className="hover:bg-slate-100 h-8 w-8"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{event.invoice_id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(createPageUrl(`Invoices?id=${event.invoice_id}`))}
|
||||
className="hover:bg-slate-100 h-8 w-8"
|
||||
title="View Invoice"
|
||||
>
|
||||
<FileText className="w-4 h-4 text-blue-600" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Ecosystem Puzzle */}
|
||||
<Card className="mb-8 border-slate-200 shadow-xl overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
|
||||
<CardTitle className="text-[#1C323E] flex items-center gap-2">
|
||||
<Target className="w-6 h-6 text-[#0A39DF]" />
|
||||
Ecosystem Connection Map
|
||||
</CardTitle>
|
||||
<p className="text-sm text-slate-500 mt-1">Interactive puzzle showing how each layer connects • Hover to see metrics • Click to explore</p>
|
||||
</CardHeader>
|
||||
<CardContent className="p-8">
|
||||
<EcosystemWheel
|
||||
layers={ecosystemLayers}
|
||||
onLayerClick={(layer) => navigate(createPageUrl(layer.route))}
|
||||
selectedLayer={selectedLayer}
|
||||
onLayerHover={setSelectedLayer}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Access Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<QuickMetrics
|
||||
title="Procurement & Vendor Intelligence"
|
||||
description="Vendor efficiency, spend analysis, compliance tracking"
|
||||
icon={Shield}
|
||||
metrics={[
|
||||
{ label: "Vendor Score", value: "A+", color: "text-green-600" },
|
||||
{ label: "Compliance", value: "98%", color: "text-blue-600" },
|
||||
{ label: "ESG Rating", value: "B+", color: "text-emerald-600" }
|
||||
]}
|
||||
route="ProcurementDashboard"
|
||||
gradient="from-[#0A39DF]/10 to-[#1C323E]/10"
|
||||
/>
|
||||
|
||||
<QuickMetrics
|
||||
title="Operator & Sector Dashboard"
|
||||
description="Live coverage, demand forecast, incident tracking"
|
||||
icon={MapPin}
|
||||
metrics={[
|
||||
{ label: "Coverage", value: "94%", color: "text-green-600" },
|
||||
{ label: "Incidents", value: "2", color: "text-yellow-600" },
|
||||
{ label: "Forecast Accuracy", value: "91%", color: "text-blue-600" }
|
||||
]}
|
||||
route="OperatorDashboard"
|
||||
gradient="from-emerald-500/10 to-emerald-700/10"
|
||||
/>
|
||||
|
||||
<QuickMetrics
|
||||
title="Vendor Dashboard"
|
||||
description="Orders, invoices, workforce pulse, KROW score"
|
||||
icon={Award}
|
||||
metrics={[
|
||||
{ label: "Fill Rate", value: "97%", color: "text-green-600" },
|
||||
{ label: "Attendance", value: "95%", color: "text-blue-600" },
|
||||
{ label: "Training", value: "92%", color: "text-purple-600" }
|
||||
]}
|
||||
route="VendorDashboard"
|
||||
gradient="from-amber-500/10 to-amber-700/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Workforce Section */}
|
||||
<Card className="border-slate-200 shadow-lg">
|
||||
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-[#1C323E] flex items-center gap-2">
|
||||
<Users className="w-6 h-6 text-[#0A39DF]" />
|
||||
Workforce Overview
|
||||
</CardTitle>
|
||||
<p className="text-sm text-slate-500 mt-1">Recent additions and active workers</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link to={createPageUrl("WorkforceDashboard")}>
|
||||
<Button variant="outline" className="border-slate-300">
|
||||
View Workforce App
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={createPageUrl("StaffDirectory")}>
|
||||
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
|
||||
View All Staff
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{recentStaff.slice(0, 3).map((member) => (
|
||||
<div key={member.id} className="p-4 rounded-lg border border-slate-200 hover:border-[#0A39DF] hover:shadow-md transition-all">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center text-white font-bold">
|
||||
{member.initial || member.employee_name?.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-[#1C323E]">{member.employee_name}</h4>
|
||||
<p className="text-sm text-slate-500">{member.position}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Rating:</span>
|
||||
<span className="font-semibold">{member.rating || 0}/5 ⭐</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Coverage:</span>
|
||||
<span className="font-semibold text-green-600">{member.shift_coverage_percentage || 0}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Cancellations:</span>
|
||||
<span className="font-semibold text-red-600">{member.cancellation_count || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1068
frontend-web-free/src/pages/EditBusiness.jsx
Normal file
1068
frontend-web-free/src/pages/EditBusiness.jsx
Normal file
File diff suppressed because it is too large
Load Diff
321
frontend-web-free/src/pages/EditEnterprise.jsx
Normal file
321
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
214
frontend-web-free/src/pages/EditEvent.jsx
Normal file
214
frontend-web-free/src/pages/EditEvent.jsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import EventFormWizard from "../components/events/EventFormWizard";
|
||||
import OrderReductionAlert from "../components/orders/OrderReductionAlert";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
export default function EditEvent() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const eventId = urlParams.get('id');
|
||||
|
||||
const [showReductionAlert, setShowReductionAlert] = useState(false);
|
||||
const [pendingUpdate, setPendingUpdate] = useState(null);
|
||||
const [originalRequested, setOriginalRequested] = useState(0);
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-edit-event'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: allEvents, isLoading } = useQuery({
|
||||
queryKey: ['events'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: allStaff = [] } = useQuery({
|
||||
queryKey: ['staff-for-reduction'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const event = allEvents.find(e => e.id === eventId);
|
||||
|
||||
useEffect(() => {
|
||||
if (event) {
|
||||
setOriginalRequested(event.requested || 0);
|
||||
}
|
||||
}, [event]);
|
||||
|
||||
const updateEventMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Event.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
navigate(createPageUrl("Events"));
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (eventData) => {
|
||||
// CRITICAL: Recalculate requested count from current roles
|
||||
const totalRequested = eventData.shifts.reduce((sum, shift) => {
|
||||
return sum + shift.roles.reduce((roleSum, role) => roleSum + (parseInt(role.count) || 0), 0);
|
||||
}, 0);
|
||||
|
||||
const assignedCount = event.assigned_staff?.length || 0;
|
||||
const isVendor = user?.user_role === 'vendor' || user?.role === 'vendor';
|
||||
|
||||
// If client is reducing headcount and vendor has already assigned staff
|
||||
if (!isVendor && totalRequested < originalRequested && assignedCount > totalRequested) {
|
||||
setPendingUpdate({ ...eventData, requested: totalRequested });
|
||||
setShowReductionAlert(true);
|
||||
|
||||
// Notify vendor via email
|
||||
if (event.vendor_name) {
|
||||
base44.integrations.Core.SendEmail({
|
||||
to: `${event.vendor_name}@example.com`,
|
||||
subject: `⚠️ Order Reduced: ${event.event_name}`,
|
||||
body: `Client has reduced headcount for order: ${event.event_name}\n\nOriginal: ${originalRequested} staff\nNew: ${totalRequested} staff\nCurrently Assigned: ${assignedCount} staff\n\nExcess: ${assignedCount - totalRequested} staff must be removed.\n\nPlease log in to adjust assignments.`
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "⚠️ Headcount Reduced",
|
||||
description: "Vendor has been notified to adjust staff assignments",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal update
|
||||
updateEventMutation.mutate({
|
||||
id: eventId,
|
||||
data: {
|
||||
...eventData,
|
||||
requested: totalRequested
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAutoUnassign = async () => {
|
||||
if (!pendingUpdate) return;
|
||||
|
||||
const assignedStaff = event.assigned_staff || [];
|
||||
const excessCount = assignedStaff.length - pendingUpdate.requested;
|
||||
|
||||
// Calculate reliability scores for assigned staff
|
||||
const staffWithScores = assignedStaff.map(assigned => {
|
||||
const staffData = allStaff.find(s => s.id === assigned.staff_id);
|
||||
return {
|
||||
...assigned,
|
||||
reliability: staffData?.reliability_score || 50,
|
||||
total_shifts: staffData?.total_shifts || 0,
|
||||
no_shows: staffData?.no_show_count || 0,
|
||||
cancellations: staffData?.cancellation_count || 0
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by reliability (lowest first)
|
||||
staffWithScores.sort((a, b) => a.reliability - b.reliability);
|
||||
|
||||
// Remove lowest reliability staff
|
||||
const staffToKeep = staffWithScores.slice(excessCount);
|
||||
|
||||
await updateEventMutation.mutateAsync({
|
||||
id: eventId,
|
||||
data: {
|
||||
...pendingUpdate,
|
||||
assigned_staff: staffToKeep.map(s => ({
|
||||
staff_id: s.staff_id,
|
||||
staff_name: s.staff_name,
|
||||
email: s.email,
|
||||
role: s.role
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
setShowReductionAlert(false);
|
||||
setPendingUpdate(null);
|
||||
|
||||
toast({
|
||||
title: "✅ Staff Auto-Unassigned",
|
||||
description: `Removed ${excessCount} lowest reliability staff members`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleManualUnassign = () => {
|
||||
setShowReductionAlert(false);
|
||||
toast({
|
||||
title: "Manual Adjustment Required",
|
||||
description: "Please manually remove excess staff from the order",
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-4">Event Not Found</h2>
|
||||
<Button onClick={() => navigate(createPageUrl("Events"))}>
|
||||
Back to Events
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate(createPageUrl("Events"))}
|
||||
className="mb-4 hover:bg-slate-100"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Events
|
||||
</Button>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 mb-2">Edit Event</h1>
|
||||
<p className="text-slate-600">Update information for {event.event_name}</p>
|
||||
</div>
|
||||
|
||||
{showReductionAlert && pendingUpdate && (
|
||||
<div className="mb-6">
|
||||
<OrderReductionAlert
|
||||
originalRequested={originalRequested}
|
||||
newRequested={pendingUpdate.requested}
|
||||
currentAssigned={event.assigned_staff?.length || 0}
|
||||
onAutoUnassign={handleAutoUnassign}
|
||||
onManualUnassign={handleManualUnassign}
|
||||
lowReliabilityStaff={(event.assigned_staff || []).map(assigned => {
|
||||
const staffData = allStaff.find(s => s.id === assigned.staff_id);
|
||||
return {
|
||||
name: assigned.staff_name,
|
||||
reliability: staffData?.reliability_score || 50
|
||||
};
|
||||
}).sort((a, b) => a.reliability - b.reliability)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EventFormWizard
|
||||
event={event}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={updateEventMutation.isPending}
|
||||
currentUser={user}
|
||||
onCancel={() => navigate(createPageUrl("Events"))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
375
frontend-web-free/src/pages/EditPartner.jsx
Normal file
375
frontend-web-free/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
frontend-web-free/src/pages/EditSector.jsx
Normal file
239
frontend-web-free/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
frontend-web-free/src/pages/EditStaff.jsx
Normal file
79
frontend-web-free/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
frontend-web-free/src/pages/EditVendor.jsx
Normal file
418
frontend-web-free/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
frontend-web-free/src/pages/EnterpriseManagement.jsx
Normal file
131
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
355
frontend-web-free/src/pages/EventDetail.jsx
Normal file
355
frontend-web-free/src/pages/EventDetail.jsx
Normal file
@@ -0,0 +1,355 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ArrowLeft, Calendar, MapPin, Users, DollarSign, Send, Edit3, X, AlertTriangle } from "lucide-react";
|
||||
import ShiftCard from "../components/events/ShiftCard";
|
||||
import OrderStatusBadge from "../components/orders/OrderStatusBadge";
|
||||
import CancellationFeeModal from "../components/orders/CancellationFeeModal";
|
||||
import { useToast } from "../components/ui/use-toast";
|
||||
import { format } from "date-fns";
|
||||
|
||||
const safeFormatDate = (dateString) => {
|
||||
if (!dateString) return "—";
|
||||
try {
|
||||
// If date is in format YYYY-MM-DD, parse it without timezone conversion
|
||||
if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||
const [year, month, day] = dateString.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
return format(date, "MMMM d, yyyy");
|
||||
}
|
||||
return format(new Date(dateString), "MMMM d, yyyy");
|
||||
} catch {
|
||||
return "—";
|
||||
}
|
||||
};
|
||||
|
||||
export default function EventDetail() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [notifyDialog, setNotifyDialog] = useState(false);
|
||||
const [cancelDialog, setCancelDialog] = useState(false);
|
||||
const [showCancellationFeeModal, setShowCancellationFeeModal] = useState(false);
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const eventId = urlParams.get("id");
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-event-detail'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: allEvents, isLoading } = useQuery({
|
||||
queryKey: ['events'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const event = allEvents.find(e => e.id === eventId);
|
||||
|
||||
// Cancel order mutation
|
||||
const cancelOrderMutation = useMutation({
|
||||
mutationFn: () => base44.entities.Event.update(eventId, { status: "Canceled" }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['all-events-client'] });
|
||||
|
||||
// Notify vendor
|
||||
if (event.vendor_name && event.vendor_id) {
|
||||
base44.integrations.Core.SendEmail({
|
||||
to: `${event.vendor_name}@example.com`,
|
||||
subject: `Order Canceled: ${event.event_name}`,
|
||||
body: `Client has canceled order: ${event.event_name}\nDate: ${event.date}\nLocation: ${event.hub || event.event_location}`
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "✅ Order Canceled",
|
||||
description: "Your order has been canceled successfully",
|
||||
});
|
||||
setShowCancellationFeeModal(false);
|
||||
navigate(createPageUrl("ClientOrders"));
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "❌ Failed to Cancel",
|
||||
description: "Could not cancel order. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleCancelClick = () => {
|
||||
setShowCancellationFeeModal(true);
|
||||
};
|
||||
|
||||
const handleConfirmCancellation = () => {
|
||||
cancelOrderMutation.mutate();
|
||||
};
|
||||
|
||||
const handleNotifyStaff = async () => {
|
||||
const assignedStaff = event?.assigned_staff || [];
|
||||
|
||||
for (const staff of assignedStaff) {
|
||||
try {
|
||||
await base44.integrations.Core.SendEmail({
|
||||
to: staff.email || `${staff.staff_name}@example.com`,
|
||||
subject: `Shift Update: ${event.event_name}`,
|
||||
body: `You have an update for: ${event.event_name}\nDate: ${event.date}\nLocation: ${event.event_location || event.hub}\n\nPlease check the platform for details.`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send email:", error);
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "✅ Notifications Sent",
|
||||
description: `Notified ${assignedStaff.length} staff members`,
|
||||
});
|
||||
setNotifyDialog(false);
|
||||
};
|
||||
|
||||
const isClient = user?.user_role === 'client' ||
|
||||
event?.created_by === user?.email ||
|
||||
event?.client_email === user?.email;
|
||||
|
||||
const canEditOrder = () => {
|
||||
if (!event) return false;
|
||||
const eventDate = new Date(event.date);
|
||||
const now = new Date();
|
||||
return isClient &&
|
||||
event.status !== "Completed" &&
|
||||
event.status !== "Canceled" &&
|
||||
eventDate > now;
|
||||
};
|
||||
|
||||
const canCancelOrder = () => {
|
||||
if (!event) return false;
|
||||
return isClient &&
|
||||
event.status !== "Completed" &&
|
||||
event.status !== "Canceled";
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<p className="text-xl font-semibold text-slate-900 mb-4">Event not found</p>
|
||||
<Link to={createPageUrl("Events")}>
|
||||
<Button variant="outline">Back to Events</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get shifts from event.shifts array (primary source)
|
||||
const eventShifts = event.shifts || [];
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900">{event.event_name}</h1>
|
||||
<p className="text-slate-600 mt-1">Order Details & Information</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<OrderStatusBadge order={event} />
|
||||
{canEditOrder() && (
|
||||
<button
|
||||
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-white hover:bg-blue-50 border-2 border-blue-200 rounded-full text-blue-600 font-semibold text-base transition-all shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Edit3 className="w-5 h-5" />
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{canCancelOrder() && (
|
||||
<button
|
||||
onClick={handleCancelClick}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-white hover:bg-red-50 border-2 border-red-200 rounded-full text-red-600 font-semibold text-base transition-all shadow-md hover:shadow-lg"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
Cancel Order
|
||||
</button>
|
||||
)}
|
||||
{!isClient && event.assigned_staff?.length > 0 && (
|
||||
<Button
|
||||
onClick={() => setNotifyDialog(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Notify Staff
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Details Card */}
|
||||
<Card className="bg-white border border-slate-200 shadow-md">
|
||||
<CardHeader className="border-b border-slate-100">
|
||||
<CardTitle className="text-lg font-bold text-slate-900">Order Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
|
||||
<Calendar className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Event Date</p>
|
||||
<p className="font-bold text-slate-900">{safeFormatDate(event.date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-50 rounded-lg flex items-center justify-center">
|
||||
<MapPin className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Location</p>
|
||||
<p className="font-bold text-slate-900">{event.hub || event.event_location || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-green-50 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Staff Assigned</p>
|
||||
<p className="font-bold text-slate-900">
|
||||
{event.assigned_staff?.length || 0} / {event.requested || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-50 rounded-lg flex items-center justify-center">
|
||||
<DollarSign className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Total Cost</p>
|
||||
<p className="font-bold text-slate-900">${(event.total || 0).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Client Information (if not client viewing) */}
|
||||
{!isClient && (
|
||||
<Card className="bg-white border border-slate-200 shadow-md">
|
||||
<CardHeader className="border-b border-slate-100">
|
||||
<CardTitle className="text-lg font-bold text-slate-900">Client Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Business Name</p>
|
||||
<p className="font-bold text-slate-900">{event.business_name || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Contact Name</p>
|
||||
<p className="font-bold text-slate-900">{event.client_name || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Contact Email</p>
|
||||
<p className="font-bold text-slate-900">{event.client_email || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Shifts - Using event.shifts array */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold text-slate-900">Event Shifts & Staff Assignment</h2>
|
||||
{eventShifts.length > 0 ? (
|
||||
eventShifts.map((shift, idx) => (
|
||||
<ShiftCard key={idx} shift={shift} event={event} currentUser={user} />
|
||||
))
|
||||
) : (
|
||||
<Card className="bg-white border border-slate-200">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Users className="w-12 h-12 mx-auto mb-4 text-slate-400" />
|
||||
<p className="text-slate-600 font-medium mb-2">No shifts defined for this event</p>
|
||||
<p className="text-slate-500 text-sm">Add roles and staff requirements to get started</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{event.notes && (
|
||||
<Card className="bg-white border border-slate-200 shadow-md">
|
||||
<CardHeader className="border-b border-slate-100">
|
||||
<CardTitle className="text-lg font-bold text-slate-900">Additional Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-slate-700 whitespace-pre-wrap">{event.notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notify Staff Dialog */}
|
||||
<Dialog open={notifyDialog} onOpenChange={setNotifyDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notify Assigned Staff</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send notification to all {event.assigned_staff?.length || 0} assigned staff members about this event.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setNotifyDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleNotifyStaff} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Send Notifications
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Cancellation Fee Modal */}
|
||||
<CancellationFeeModal
|
||||
open={showCancellationFeeModal}
|
||||
onClose={() => setShowCancellationFeeModal(false)}
|
||||
onConfirm={handleConfirmCancellation}
|
||||
event={event}
|
||||
isSubmitting={cancelOrderMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
710
frontend-web-free/src/pages/Events.jsx
Normal file
710
frontend-web-free/src/pages/Events.jsx
Normal file
@@ -0,0 +1,710 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Plus, Search, Calendar as CalendarIcon, Eye, Edit, Copy, X, RefreshCw, Users, Sparkles, Zap, Target, Clock, MapPin, Shield, DollarSign, TrendingUp, List, LayoutGrid, FileText, MoreVertical, AlertTriangle, CheckCircle, UserCog } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { format, isSameDay, parseISO, isWithinInterval, startOfDay, endOfDay, isValid } from "date-fns";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import SmartAssignModal from "../components/events/SmartAssignModal";
|
||||
import DragDropScheduler from "../components/scheduling/DragDropScheduler";
|
||||
import AutomationEngine from "../components/scheduling/AutomationEngine";
|
||||
import { autoFillShifts } from "../components/scheduling/SmartAssignmentEngine";
|
||||
import { detectAllConflicts, ConflictAlert } from "../components/scheduling/ConflictDetection";
|
||||
|
||||
const safeParseDate = (dateString) => {
|
||||
if (!dateString) return null;
|
||||
try {
|
||||
// If date is in format YYYY-MM-DD, parse it without timezone conversion
|
||||
if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||
const [year, month, day] = dateString.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
return isValid(date) ? date : null;
|
||||
}
|
||||
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
|
||||
return isValid(date) ? date : null;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
const safeFormatDate = (dateString, formatStr) => {
|
||||
const date = safeParseDate(dateString);
|
||||
if (!date) return "-";
|
||||
try { return format(date, formatStr); } catch { return "-"; }
|
||||
};
|
||||
|
||||
const convertTo12Hour = (time24) => {
|
||||
if (!time24) return "-";
|
||||
try {
|
||||
const [hours, minutes] = time24.split(':');
|
||||
const hour = parseInt(hours);
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = hour % 12 || 12;
|
||||
return `${hour12}:${minutes} ${ampm}`;
|
||||
} catch {
|
||||
return time24;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (event, hasConflicts) => {
|
||||
if (event.is_rapid) {
|
||||
return (
|
||||
<div className="relative inline-flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md">
|
||||
<Zap className="w-3.5 h-3.5 fill-white" />
|
||||
RAPID
|
||||
{hasConflicts && (
|
||||
<AlertTriangle className="w-3 h-3 absolute -top-1 -right-1 text-orange-500 bg-white rounded-full p-0.5" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
'Draft': { bg: 'bg-slate-500', icon: FileText },
|
||||
'Pending': { bg: 'bg-amber-500', icon: Clock },
|
||||
'Partial Staffed': { bg: 'bg-orange-500', icon: AlertTriangle },
|
||||
'Fully Staffed': { bg: 'bg-emerald-500', icon: CheckCircle },
|
||||
'Active': { bg: 'bg-blue-500', icon: Users },
|
||||
'Completed': { bg: 'bg-slate-400', icon: CheckCircle },
|
||||
'Canceled': { bg: 'bg-red-500', icon: X },
|
||||
};
|
||||
|
||||
const config = statusConfig[event.status] || { bg: 'bg-slate-400', icon: Clock };
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className={`relative inline-flex items-center gap-2 ${config.bg} text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{event.status}
|
||||
{hasConflicts && (
|
||||
<AlertTriangle className="w-3 h-3 absolute -top-1 -right-1 text-orange-500 bg-white rounded-full p-0.5" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Events() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [viewMode, setViewMode] = useState("table");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const { toast } = useToast();
|
||||
const [assignModal, setAssignModal] = useState({ open: false, event: null, shift: null, role: null });
|
||||
const [isAutoAssigning, setIsAutoAssigning] = useState(false);
|
||||
const [showConflicts, setShowConflicts] = useState(true);
|
||||
|
||||
const [assignmentOptions, setAssignmentOptions] = useState({
|
||||
prioritizeSkill: true,
|
||||
prioritizeReliability: true,
|
||||
prioritizeVendor: true,
|
||||
prioritizeFatigue: true,
|
||||
prioritizeCompliance: true,
|
||||
prioritizeProximity: true,
|
||||
prioritizeCost: false,
|
||||
});
|
||||
|
||||
const { data: events, isLoading } = useQuery({
|
||||
queryKey: ['events'],
|
||||
queryFn: () => base44.entities.Event.list('-date'),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: allStaff = [] } = useQuery({
|
||||
queryKey: ['staff-for-auto-assign'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
});
|
||||
|
||||
const { data: vendorRates = [] } = useQuery({
|
||||
queryKey: ['vendor-rates-auto-assign'],
|
||||
queryFn: () => base44.entities.VendorRate.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const updateEventMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Event.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
},
|
||||
});
|
||||
|
||||
const eventsWithConflicts = React.useMemo(() => {
|
||||
return events.map(event => {
|
||||
const conflicts = detectAllConflicts(event, events);
|
||||
return { ...event, detected_conflicts: conflicts };
|
||||
});
|
||||
}, [events]);
|
||||
|
||||
const totalConflicts = eventsWithConflicts.reduce((sum, e) => sum + (e.detected_conflicts?.length || 0), 0);
|
||||
|
||||
const autoAssignMutation = useMutation({
|
||||
mutationFn: async (event) => {
|
||||
const assignments = await autoFillShifts(event, allStaff, events, vendorRates, assignmentOptions);
|
||||
if (assignments.length === 0) throw new Error("No suitable staff found");
|
||||
|
||||
const updatedAssignedStaff = [...(event.assigned_staff || []), ...assignments];
|
||||
const updatedShifts = (event.shifts || []).map(shift => {
|
||||
const updatedRoles = (shift.roles || []).map(role => {
|
||||
const roleAssignments = assignments.filter(a => a.role === role.role);
|
||||
return { ...role, assigned: (role.assigned || 0) + roleAssignments.length };
|
||||
});
|
||||
return { ...shift, roles: updatedRoles };
|
||||
});
|
||||
|
||||
const totalRequested = updatedShifts.reduce((accShift, shift) => {
|
||||
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
|
||||
}, 0);
|
||||
|
||||
const totalAssigned = updatedAssignedStaff.length;
|
||||
let newStatus = event.status;
|
||||
|
||||
if (totalAssigned >= totalRequested && totalRequested > 0) {
|
||||
newStatus = 'Fully Staffed';
|
||||
} else if (totalAssigned > 0 && totalAssigned < totalRequested) {
|
||||
newStatus = 'Partial Staffed';
|
||||
} else if (totalAssigned === 0) {
|
||||
newStatus = 'Pending';
|
||||
}
|
||||
|
||||
await base44.entities.Event.update(event.id, {
|
||||
assigned_staff: updatedAssignedStaff,
|
||||
shifts: updatedShifts,
|
||||
requested: (event.requested || 0) + assignments.length,
|
||||
status: newStatus,
|
||||
});
|
||||
|
||||
return assignments.length;
|
||||
},
|
||||
onSuccess: (count) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
toast({ title: "✅ Auto-Assigned", description: `Assigned ${count} staff automatically` });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({ title: "⚠️ Auto-Assign Failed", description: error.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleAssign = async (eventId, staffMember) => {
|
||||
const event = events.find(e => e.id === eventId);
|
||||
if (!event) return;
|
||||
|
||||
const updatedAssignedStaff = [
|
||||
...(event.assigned_staff || []),
|
||||
{ staff_id: staffMember.id, staff_name: staffMember.employee_name, email: staffMember.email, role: staffMember.position }
|
||||
];
|
||||
|
||||
const totalRequested = event.shifts?.reduce((accShift, shift) => {
|
||||
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
|
||||
}, 0) || 0;
|
||||
|
||||
let newStatus = event.status;
|
||||
|
||||
if (updatedAssignedStaff.length >= totalRequested && totalRequested > 0) {
|
||||
newStatus = 'Fully Staffed';
|
||||
} else if (updatedAssignedStaff.length > 0 && updatedAssignedStaff.length < totalRequested) {
|
||||
newStatus = 'Partial Staffed';
|
||||
}
|
||||
|
||||
await updateEventMutation.mutateAsync({
|
||||
id: eventId,
|
||||
data: {
|
||||
assigned_staff: updatedAssignedStaff,
|
||||
status: newStatus,
|
||||
}
|
||||
});
|
||||
toast({ title: "✅ Staff Assigned", description: `${staffMember.employee_name} assigned to event` });
|
||||
};
|
||||
|
||||
const handleUnassign = async (eventId, staffId) => {
|
||||
const event = events.find(e => e.id === eventId);
|
||||
if (!event) return;
|
||||
|
||||
const updatedAssignedStaff = event.assigned_staff.filter(s => s.staff_id !== staffId);
|
||||
|
||||
const totalRequested = event.shifts?.reduce((accShift, shift) => {
|
||||
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
|
||||
}, 0) || 0;
|
||||
|
||||
let newStatus = event.status;
|
||||
|
||||
if (updatedAssignedStaff.length >= totalRequested && totalRequested > 0) {
|
||||
newStatus = 'Fully Staffed';
|
||||
} else if (updatedAssignedStaff.length > 0 && updatedAssignedStaff.length < totalRequested) {
|
||||
newStatus = 'Partial Staffed';
|
||||
} else if (updatedAssignedStaff.length === 0) {
|
||||
newStatus = 'Pending';
|
||||
}
|
||||
|
||||
await updateEventMutation.mutateAsync({
|
||||
id: eventId,
|
||||
data: {
|
||||
assigned_staff: updatedAssignedStaff,
|
||||
status: newStatus,
|
||||
}
|
||||
});
|
||||
toast({ title: "Staff Unassigned", description: "Staff member removed from event" });
|
||||
};
|
||||
|
||||
const handleAutoAssignEvent = (event) => autoAssignMutation.mutate(event);
|
||||
|
||||
const getStatusCounts = () => {
|
||||
const total = events.length;
|
||||
const active = events.filter(e => e.status === "Active").length;
|
||||
const pending = events.filter(e => e.status === "Pending").length;
|
||||
const partialStaffed = events.filter(e => e.status === "Partial Staffed").length;
|
||||
const fullyStaffed = events.filter(e => e.status === "Fully Staffed").length;
|
||||
const completed = events.filter(e => e.status === "Completed").length;
|
||||
|
||||
return {
|
||||
active: { count: active, percentage: total ? Math.round((active / total) * 100) : 0 },
|
||||
pending: { count: pending, percentage: total ? Math.round((pending / total) * 100) : 0 },
|
||||
partialStaffed: { count: partialStaffed, percentage: total ? Math.round((partialStaffed / total) * 100) : 0 },
|
||||
fullyStaffed: { count: fullyStaffed, percentage: total ? Math.round((fullyStaffed / total) * 100) : 0 },
|
||||
completed: { count: completed, percentage: total ? Math.round((completed / total) * 100) : 0 },
|
||||
};
|
||||
};
|
||||
|
||||
const getFilteredEvents = () => {
|
||||
let filtered = eventsWithConflicts;
|
||||
|
||||
if (activeTab === "last_minute") filtered = filtered.filter(e => e.event_type === "Last Minute Request");
|
||||
else if (activeTab === "upcoming") filtered = filtered.filter(e => { const eventDate = safeParseDate(e.date); return eventDate && eventDate > new Date(); });
|
||||
else if (activeTab === "active") filtered = filtered.filter(e => e.status === "Active");
|
||||
else if (activeTab === "fully_staffed") filtered = filtered.filter(e => e.status === "Fully Staffed");
|
||||
else if (activeTab === "canceled") filtered = filtered.filter(e => e.status === "Canceled");
|
||||
else if (activeTab === "past") filtered = filtered.filter(e => e.status === "Completed");
|
||||
else if (activeTab === "conflicts") filtered = filtered.filter(e => e.detected_conflicts && e.detected_conflicts.length > 0);
|
||||
else if (activeTab === "draft") filtered = filtered.filter(e => e.status === "Draft");
|
||||
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(e =>
|
||||
e.event_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
e.hub?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
e.business_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
e.id?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const statusCounts = getStatusCounts();
|
||||
const filteredEvents = getFilteredEvents();
|
||||
|
||||
const unassignedStaff = allStaff.filter(staff => !events.some(e => e.assigned_staff?.some(s => s.staff_id === staff.id)));
|
||||
const upcomingEvents = filteredEvents.filter(e => {
|
||||
const eventDate = new Date(e.date);
|
||||
return eventDate >= new Date() && e.status !== 'Completed' && e.status !== 'Canceled';
|
||||
}).slice(0, 10);
|
||||
|
||||
const getTabCount = (tab) => {
|
||||
if (tab === "all") return events.length;
|
||||
if (tab === "conflicts") return eventsWithConflicts.filter(e => e.detected_conflicts && e.detected_conflicts.length > 0).length;
|
||||
if (tab === "last_minute") return events.filter(e => e.event_type === "Last Minute Request").length;
|
||||
if (tab === "upcoming") return events.filter(e => { const eventDate = safeParseDate(e.date); return eventDate && eventDate > new Date(); }).length;
|
||||
if (tab === "active") return events.filter(e => e.status === "Active").length;
|
||||
if (tab === "fully_staffed") return events.filter(e => e.status === "Fully Staffed").length;
|
||||
if (tab === "canceled") return events.filter(e => e.status === "Canceled").length;
|
||||
if (tab === "past") return events.filter(e => e.status === "Completed").length;
|
||||
if (tab === "draft") return events.filter(e => e.status === "Draft").length;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const getAssignmentStatus = (event) => {
|
||||
const totalRequested = event.shifts?.reduce((accShift, shift) => {
|
||||
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
|
||||
}, 0) || 0;
|
||||
|
||||
const assigned = event.assigned_staff?.length || 0;
|
||||
const fillPercent = totalRequested > 0 ? Math.round((assigned / totalRequested) * 100) : 0;
|
||||
|
||||
if (assigned === 0) return { color: 'bg-slate-100 text-slate-600', text: '0', percent: '0%', status: 'empty' };
|
||||
if (totalRequested > 0 && assigned >= totalRequested) return { color: 'bg-emerald-500 text-white', text: assigned, percent: '100%', status: 'full' };
|
||||
if (totalRequested > 0 && assigned < totalRequested) return { color: 'bg-orange-500 text-white', text: assigned, percent: `${fillPercent}%`, status: 'partial' };
|
||||
return { color: 'bg-slate-500 text-white', text: assigned, percent: '0%', status: 'partial' };
|
||||
};
|
||||
|
||||
const getEventTimes = (event) => {
|
||||
const firstShift = event.shifts?.[0];
|
||||
const rolesInFirstShift = firstShift?.roles || [];
|
||||
|
||||
let startTime = null;
|
||||
let endTime = null;
|
||||
|
||||
if (rolesInFirstShift.length > 0) {
|
||||
startTime = rolesInFirstShift[0].start_time || null;
|
||||
endTime = rolesInFirstShift[0].end_time || null;
|
||||
}
|
||||
|
||||
return {
|
||||
startTime: startTime ? convertTo12Hour(startTime) : "-",
|
||||
endTime: endTime ? convertTo12Hour(endTime) : "-"
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-[1800px] mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Order Management</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">View, assign, and track all your orders</p>
|
||||
</div>
|
||||
|
||||
{showConflicts && totalConflicts > 0 && (
|
||||
<Alert className="mb-6 border-2 border-orange-500 bg-orange-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<AlertDescription className="font-semibold text-orange-900">
|
||||
{totalConflicts} scheduling conflict{totalConflicts !== 1 ? 's' : ''} detected
|
||||
</AlertDescription>
|
||||
<p className="text-sm text-orange-700 mt-1">
|
||||
Click the "Conflicts" tab to review and resolve overlapping bookings
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowConflicts(false)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card className="border border-red-200 bg-red-50">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-red-500 rounded-lg flex items-center justify-center">
|
||||
<Zap className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-red-600 font-semibold uppercase">RAPID</p>
|
||||
<p className="text-2xl font-bold text-red-700">{events.filter(e => e.is_rapid).length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-amber-200 bg-amber-50">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
|
||||
<Clock className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-amber-600 font-semibold uppercase">REQUESTED</p>
|
||||
<p className="text-2xl font-bold text-amber-700">{events.filter(e => e.status === 'Pending').length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-orange-200 bg-orange-50">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-orange-600 font-semibold uppercase">PARTIAL</p>
|
||||
<p className="text-2xl font-bold text-orange-700">{events.filter(e => {
|
||||
const status = getAssignmentStatus(e);
|
||||
return status.status === 'partial';
|
||||
}).length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-emerald-200 bg-emerald-50">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-emerald-500 rounded-lg flex items-center justify-center">
|
||||
<CheckCircle className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-emerald-600 font-semibold uppercase">FULLY STAFFED</p>
|
||||
<p className="text-2xl font-bold text-emerald-700">{events.filter(e => {
|
||||
const status = getAssignmentStatus(e);
|
||||
return status.status === 'full';
|
||||
}).length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 mb-6 border-2 shadow-md">
|
||||
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input placeholder="Search by event, business, or location..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="pl-10 border-slate-200 h-11" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-gradient-to-r from-blue-50 to-indigo-50 p-2 rounded-xl border-2 border-blue-200">
|
||||
<Button
|
||||
variant={viewMode === "table" ? "default" : "ghost"}
|
||||
size="lg"
|
||||
onClick={() => setViewMode("table")}
|
||||
className={`${viewMode === "table" ? "bg-blue-600 text-white hover:bg-blue-700 shadow-lg" : "hover:bg-white/50"} h-11 px-6 font-semibold cursor-pointer`}
|
||||
>
|
||||
<List className="w-5 h-5 mr-2" />
|
||||
Table View
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "scheduler" ? "default" : "ghost"}
|
||||
size="lg"
|
||||
onClick={() => setViewMode("scheduler")}
|
||||
className={`${viewMode === "scheduler" ? "bg-blue-600 text-white hover:bg-blue-700 shadow-lg" : "hover:bg-white/50"} h-11 px-6 font-semibold cursor-pointer`}
|
||||
>
|
||||
<LayoutGrid className="w-5 h-5 mr-2" />
|
||||
Scheduler View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
|
||||
<TabsList className="bg-white border">
|
||||
<TabsTrigger value="all">All ({getTabCount("all")})</TabsTrigger>
|
||||
<TabsTrigger value="conflicts" className="data-[state=active]:bg-orange-500 data-[state=active]:text-white">
|
||||
<AlertTriangle className="w-4 h-4 mr-2" />
|
||||
Conflicts ({getTabCount("conflicts")})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="upcoming">Upcoming ({getTabCount("upcoming")})</TabsTrigger>
|
||||
<TabsTrigger value="active">Active ({getTabCount("active")})</TabsTrigger>
|
||||
<TabsTrigger value="fully_staffed">Fully Staffed ({getTabCount("fully_staffed")})</TabsTrigger>
|
||||
<TabsTrigger value="past">Past ({getTabCount("past")})</TabsTrigger>
|
||||
<TabsTrigger value="draft">Draft ({getTabCount("draft")})</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{viewMode === "scheduler" && (
|
||||
<>
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="w-5 h-5" />
|
||||
Smart Assignment Logic
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="skill" className="flex items-center gap-2"><Users className="w-4 h-4" />Skill Match</Label>
|
||||
<Switch id="skill" checked={assignmentOptions.prioritizeSkill} onCheckedChange={(checked) => setAssignmentOptions(prev => ({...prev, prioritizeSkill: checked}))} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="reliability" className="flex items-center gap-2"><TrendingUp className="w-4 h-4" />Reliability</Label>
|
||||
<Switch id="reliability" checked={assignmentOptions.prioritizeReliability} onCheckedChange={(checked) => setAssignmentOptions(prev => ({...prev, prioritizeReliability: checked}))} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="fatigue" className="flex items-center gap-2"><Clock className="w-4 h-4" />Low Fatigue</Label>
|
||||
<Switch id="fatigue" checked={assignmentOptions.prioritizeFatigue} onCheckedChange={(checked) => setAssignmentOptions(prev => ({...prev, prioritizeFatigue: checked}))} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="compliance" className="flex items-center gap-2"><Shield className="w-4 h-4" />Compliance</Label>
|
||||
<Switch id="compliance" checked={assignmentOptions.prioritizeCompliance} onCheckedChange={(checked) => setAssignmentOptions(prev => ({...prev, prioritizeCompliance: checked}))} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="scheduler" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="scheduler">Drag & Drop Scheduler</TabsTrigger>
|
||||
<TabsTrigger value="automations">Active Automations</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="scheduler">
|
||||
<DragDropScheduler events={upcomingEvents} staff={unassignedStaff} onAssign={handleAssign} onUnassign={handleUnassign} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="automations">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Automations</CardTitle>
|
||||
<p className="text-sm text-slate-500">Background processes reducing manual work by 85%</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center"><Zap className="w-5 h-5 text-green-600" /></div>
|
||||
<div><p className="font-semibold text-sm">Auto-Fill Open Shifts</p><p className="text-xs text-slate-500">Automatically assigns best-fit staff to unfilled roles</p></div>
|
||||
</div>
|
||||
<Badge className="bg-green-100 text-green-700">Active</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center"><Shield className="w-5 h-5 text-blue-600" /></div>
|
||||
<div><p className="font-semibold text-sm">Auto-Confirm Workers</p><p className="text-xs text-slate-500">Confirms staff 24 hours before shift</p></div>
|
||||
</div>
|
||||
<Badge className="bg-blue-100 text-blue-700">Active</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center"><Clock className="w-5 h-5 text-purple-600" /></div>
|
||||
<div><p className="font-semibold text-sm">Auto-Send Reminders</p><p className="text-xs text-slate-500">Sends reminders 2 hours before shift start</p></div>
|
||||
</div>
|
||||
<Badge className="bg-purple-100 text-purple-700">Active</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<AutomationEngine />
|
||||
</>
|
||||
)}
|
||||
|
||||
{viewMode === "table" && (
|
||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 hover:bg-slate-50 border-b">
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide h-10">BUSINESS</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">HUB</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">EVENT</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">DATE & TIME</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">STATUS</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">REQUESTED</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ASSIGNED</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">INVOICE</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ACTIONS</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredEvents.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={9} className="text-center py-12 text-slate-500"><CalendarIcon className="w-12 h-12 mx-auto mb-3 text-slate-300" /><p className="font-medium">No events found</p></TableCell></TableRow>
|
||||
) : (
|
||||
filteredEvents.map((event) => {
|
||||
const assignmentStatus = getAssignmentStatus(event);
|
||||
const showAutoButton = assignmentStatus.status !== 'full' && event.status !== 'Canceled' && event.status !== 'Completed' && event.status !== 'Fully Staffed';
|
||||
const hasConflicts = event.detected_conflicts && event.detected_conflicts.length > 0;
|
||||
const eventTimes = getEventTimes(event);
|
||||
const eventDate = safeParseDate(event.date);
|
||||
const dayOfWeek = eventDate ? format(eventDate, 'EEEE') : '';
|
||||
|
||||
return (
|
||||
<React.Fragment key={event.id}>
|
||||
<TableRow className="hover:bg-slate-50 transition-colors border-b">
|
||||
<TableCell className="py-3">
|
||||
<p className="text-sm text-slate-700 font-medium">{event.business_name || "—"}</p>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<div className="flex items-center gap-1.5 text-sm text-slate-500">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
{event.hub || event.event_location || "Main Hub"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<p className="font-semibold text-slate-900 text-sm">{event.event_name}</p>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm text-slate-900 font-semibold">{eventDate ? format(eventDate, 'MM.dd.yyyy') : '-'}</p>
|
||||
<p className="text-xs text-slate-500">{dayOfWeek}</p>
|
||||
<div className="flex items-center gap-1 text-xs text-slate-600 mt-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{eventTimes.startTime} - {eventTimes.endTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
{getStatusBadge(event, hasConflicts)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-3">
|
||||
<span className="font-semibold text-slate-700 text-sm">{event.requested || 0}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-3">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Badge className={`${assignmentStatus.color} font-bold px-3 py-1 rounded-full text-xs`}>
|
||||
{assignmentStatus.text}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-slate-500 font-medium">{assignmentStatus.percent}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-3">
|
||||
{event.status === 'Completed' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(createPageUrl('Invoices'))}
|
||||
className="hover:bg-slate-100 h-8 w-8 mx-auto"
|
||||
title="View Invoice"
|
||||
>
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-slate-300">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{showAutoButton && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleAutoAssignEvent(event)}
|
||||
className="h-8 px-2 hover:bg-slate-100"
|
||||
title="Smart Assign"
|
||||
>
|
||||
<UserCog className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(createPageUrl(`EventDetail?id=${event.id}`))}
|
||||
className="hover:bg-slate-100 h-8 w-8"
|
||||
title="View"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
|
||||
className="hover:bg-slate-100 h-8 w-8"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{hasConflicts && activeTab === "conflicts" && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="bg-orange-50/50 py-4">
|
||||
<ConflictAlert conflicts={event.detected_conflicts} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SmartAssignModal open={assignModal.open} onClose={() => setAssignModal({ open: false, event: null, shift: null, role: null })} event={assignModal.event} shift={assignModal.shift} role={assignModal.role} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
frontend-web-free/src/pages/Home.jsx
Normal file
55
frontend-web-free/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
frontend-web-free/src/pages/InviteVendor.jsx
Normal file
380
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
85
frontend-web-free/src/pages/InvoiceDetail.jsx
Normal file
85
frontend-web-free/src/pages/InvoiceDetail.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import InvoiceDetailView from "../components/invoices/InvoiceDetailView";
|
||||
import InvoiceExportPanel from "../components/invoices/InvoiceExportPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export default function InvoiceDetail() {
|
||||
const navigate = useNavigate();
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const invoiceId = urlParams.get('id');
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-invoice-detail'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: invoices = [], isLoading } = useQuery({
|
||||
queryKey: ['invoices'],
|
||||
queryFn: () => base44.entities.Invoice.list(),
|
||||
});
|
||||
|
||||
const { data: businesses = [] } = useQuery({
|
||||
queryKey: ['businesses-for-invoice'],
|
||||
queryFn: () => base44.entities.Business.list(),
|
||||
});
|
||||
|
||||
const invoice = invoices.find(inv => inv.id === invoiceId);
|
||||
const business = businesses.find(b =>
|
||||
b.business_name === invoice?.business_name ||
|
||||
b.business_name === invoice?.hub
|
||||
);
|
||||
const userRole = user?.user_role || user?.role;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-[#0A39DF] border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-slate-600">Loading invoice...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<p className="text-xl font-semibold text-slate-900 mb-4">Invoice not found</p>
|
||||
<Button onClick={() => navigate(createPageUrl('Invoices'))}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Invoices
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed top-20 left-4 z-50 print:hidden">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(createPageUrl('Invoices'))}
|
||||
className="bg-white shadow-lg"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-6 p-4 md:p-8">
|
||||
<div className="flex-1">
|
||||
<InvoiceDetailView invoice={invoice} userRole={userRole} />
|
||||
</div>
|
||||
<div className="w-80 flex-shrink-0 print:hidden">
|
||||
<InvoiceExportPanel invoice={invoice} business={business} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
869
frontend-web-free/src/pages/InvoiceEditor.jsx
Normal file
869
frontend-web-free/src/pages/InvoiceEditor.jsx
Normal file
@@ -0,0 +1,869 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowLeft, Plus, Trash2, Clock } from "lucide-react";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { format, addDays } from "date-fns";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
export default function InvoiceEditor() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const invoiceId = urlParams.get('id');
|
||||
const isEdit = !!invoiceId;
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-invoice-editor'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: invoices = [] } = useQuery({
|
||||
queryKey: ['invoices'],
|
||||
queryFn: () => base44.entities.Invoice.list(),
|
||||
enabled: isEdit,
|
||||
});
|
||||
|
||||
const { data: events = [] } = useQuery({
|
||||
queryKey: ['events-for-invoice'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
});
|
||||
|
||||
const existingInvoice = invoices.find(inv => inv.id === invoiceId);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
invoice_number: existingInvoice?.invoice_number || `INV-G00G${Math.floor(Math.random() * 100000)}`,
|
||||
event_id: existingInvoice?.event_id || "",
|
||||
event_name: existingInvoice?.event_name || "",
|
||||
invoice_date: existingInvoice?.issue_date || format(new Date(), 'yyyy-MM-dd'),
|
||||
due_date: existingInvoice?.due_date || format(addDays(new Date(), 30), 'yyyy-MM-dd'),
|
||||
payment_terms: existingInvoice?.payment_terms || "30",
|
||||
hub: existingInvoice?.hub || "",
|
||||
manager: existingInvoice?.manager_name || "",
|
||||
vendor_id: existingInvoice?.vendor_id || "",
|
||||
department: existingInvoice?.department || "",
|
||||
po_reference: existingInvoice?.po_reference || "",
|
||||
from_company: existingInvoice?.from_company || {
|
||||
name: "Legendary Event Staffing",
|
||||
address: "848 E Gish Rd Ste 1, San Jose, CA 95112",
|
||||
phone: "(408) 936-0180",
|
||||
email: "order@legendaryeventstaff.com"
|
||||
},
|
||||
to_company: existingInvoice?.to_company || {
|
||||
name: "Thinkloops",
|
||||
phone: "4086702861",
|
||||
email: "mohsin@thikloops.com",
|
||||
address: "Dublin St, San Francisco, CA 94112, USA",
|
||||
manager_name: "Manager Name",
|
||||
hub_name: "Hub Name",
|
||||
vendor_id: "Vendor #"
|
||||
},
|
||||
staff_entries: existingInvoice?.roles?.[0]?.staff_entries || [],
|
||||
charges: existingInvoice?.charges || [],
|
||||
other_charges: existingInvoice?.other_charges || 0,
|
||||
notes: existingInvoice?.notes || "",
|
||||
});
|
||||
|
||||
const [timePickerOpen, setTimePickerOpen] = useState(null);
|
||||
const [selectedTime, setSelectedTime] = useState({ hours: "09", minutes: "00", period: "AM" });
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (data) => {
|
||||
// Calculate totals
|
||||
const staffTotal = data.staff_entries.reduce((sum, entry) => sum + (entry.total || 0), 0);
|
||||
const chargesTotal = data.charges.reduce((sum, charge) => sum + ((charge.qty * charge.rate) || 0), 0);
|
||||
const subtotal = staffTotal + chargesTotal;
|
||||
const total = subtotal + (parseFloat(data.other_charges) || 0);
|
||||
|
||||
const roles = data.staff_entries.length > 0 ? [{
|
||||
role_name: "Mixed",
|
||||
staff_entries: data.staff_entries,
|
||||
role_subtotal: staffTotal
|
||||
}] : [];
|
||||
|
||||
const invoiceData = {
|
||||
invoice_number: data.invoice_number,
|
||||
event_id: data.event_id,
|
||||
event_name: data.event_name,
|
||||
event_date: data.invoice_date,
|
||||
po_reference: data.po_reference,
|
||||
from_company: data.from_company,
|
||||
to_company: data.to_company,
|
||||
business_name: data.to_company.name,
|
||||
manager_name: data.manager,
|
||||
vendor_name: data.from_company.name,
|
||||
vendor_id: data.vendor_id,
|
||||
hub: data.hub,
|
||||
department: data.department,
|
||||
cost_center: data.po_reference,
|
||||
roles: roles,
|
||||
charges: data.charges,
|
||||
subtotal: subtotal,
|
||||
other_charges: parseFloat(data.other_charges) || 0,
|
||||
amount: total,
|
||||
status: existingInvoice?.status || "Draft",
|
||||
issue_date: data.invoice_date,
|
||||
due_date: data.due_date,
|
||||
payment_terms: data.payment_terms,
|
||||
is_auto_generated: false,
|
||||
notes: data.notes,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
return base44.entities.Invoice.update(invoiceId, invoiceData);
|
||||
} else {
|
||||
return base44.entities.Invoice.create(invoiceData);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
toast({
|
||||
title: isEdit ? "✅ Invoice Updated" : "✅ Invoice Created",
|
||||
description: isEdit ? "Invoice has been updated successfully" : "Invoice has been created successfully",
|
||||
});
|
||||
navigate(createPageUrl('Invoices'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddStaffEntry = () => {
|
||||
setFormData({
|
||||
...formData,
|
||||
staff_entries: [
|
||||
...formData.staff_entries,
|
||||
{
|
||||
name: "Mohsin",
|
||||
date: format(new Date(), 'MM/dd/yyyy'),
|
||||
position: "Bartender",
|
||||
check_in: "hh:mm",
|
||||
lunch: 0,
|
||||
check_out: "",
|
||||
worked_hours: 0,
|
||||
regular_hours: 0,
|
||||
ot_hours: 0,
|
||||
dt_hours: 0,
|
||||
rate: 52.68,
|
||||
regular_value: 0,
|
||||
ot_value: 0,
|
||||
dt_value: 0,
|
||||
total: 0
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddCharge = () => {
|
||||
setFormData({
|
||||
...formData,
|
||||
charges: [
|
||||
...formData.charges,
|
||||
{
|
||||
name: "Gas Compensation",
|
||||
qty: 7.30,
|
||||
rate: 0,
|
||||
price: 0
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
const handleStaffChange = (index, field, value) => {
|
||||
const newEntries = [...formData.staff_entries];
|
||||
newEntries[index] = { ...newEntries[index], [field]: value };
|
||||
|
||||
// Recalculate totals if time-related fields change
|
||||
if (['worked_hours', 'regular_hours', 'ot_hours', 'dt_hours', 'rate'].includes(field)) {
|
||||
const entry = newEntries[index];
|
||||
entry.regular_value = (entry.regular_hours || 0) * (entry.rate || 0);
|
||||
entry.ot_value = (entry.ot_hours || 0) * (entry.rate || 0) * 1.5;
|
||||
entry.dt_value = (entry.dt_hours || 0) * (entry.rate || 0) * 2;
|
||||
entry.total = entry.regular_value + entry.ot_value + entry.dt_value;
|
||||
}
|
||||
|
||||
setFormData({ ...formData, staff_entries: newEntries });
|
||||
};
|
||||
|
||||
const handleChargeChange = (index, field, value) => {
|
||||
const newCharges = [...formData.charges];
|
||||
newCharges[index] = { ...newCharges[index], [field]: value };
|
||||
|
||||
if (['qty', 'rate'].includes(field)) {
|
||||
newCharges[index].price = (newCharges[index].qty || 0) * (newCharges[index].rate || 0);
|
||||
}
|
||||
|
||||
setFormData({ ...formData, charges: newCharges });
|
||||
};
|
||||
|
||||
const handleRemoveStaff = (index) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
staff_entries: formData.staff_entries.filter((_, i) => i !== index)
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveCharge = (index) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
charges: formData.charges.filter((_, i) => i !== index)
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeSelect = (entryIndex, field) => {
|
||||
const timeString = `${selectedTime.hours}:${selectedTime.minutes} ${selectedTime.period}`;
|
||||
handleStaffChange(entryIndex, field, timeString);
|
||||
setTimePickerOpen(null);
|
||||
};
|
||||
|
||||
const calculateTotals = () => {
|
||||
const staffTotal = formData.staff_entries.reduce((sum, entry) => sum + (entry.total || 0), 0);
|
||||
const chargesTotal = formData.charges.reduce((sum, charge) => sum + (charge.price || 0), 0);
|
||||
const subtotal = staffTotal + chargesTotal;
|
||||
const otherCharges = parseFloat(formData.other_charges) || 0;
|
||||
const grandTotal = subtotal + otherCharges;
|
||||
|
||||
return { subtotal, otherCharges, grandTotal };
|
||||
};
|
||||
|
||||
const totals = calculateTotals();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-slate-50 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" onClick={() => navigate(createPageUrl('Invoices'))} className="bg-white">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Invoices
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{isEdit ? 'Edit Invoice' : 'Create New Invoice'}</h1>
|
||||
<p className="text-sm text-slate-600">Complete all invoice details below</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="bg-blue-100 text-blue-700 text-sm px-3 py-1">
|
||||
{existingInvoice?.status || "Draft"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card className="p-8 bg-white shadow-lg border-blue-100">
|
||||
{/* Invoice Details Header */}
|
||||
<div className="flex items-start justify-between mb-6 pb-6 border-b border-blue-100">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-lg">📄</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Invoice Details</h2>
|
||||
<p className="text-sm text-slate-500">Event: {formData.event_name || "Internal Support"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-blue-50 to-blue-100 p-4 rounded-lg mb-4">
|
||||
<div className="text-xs text-blue-600 font-semibold mb-1">Invoice Number</div>
|
||||
<div className="font-bold text-2xl text-blue-900">{formData.invoice_number}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<Label className="text-xs font-semibold text-slate-700">Invoice Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.invoice_date}
|
||||
onChange={(e) => setFormData({ ...formData, invoice_date: e.target.value })}
|
||||
className="mt-1 border-blue-200 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-semibold text-slate-700">Due Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.due_date}
|
||||
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
|
||||
className="mt-1 border-blue-200 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Label className="text-xs">Hub</Label>
|
||||
<Input
|
||||
value={formData.hub}
|
||||
onChange={(e) => setFormData({ ...formData, hub: e.target.value })}
|
||||
placeholder="Hub"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Label className="text-xs">Manager</Label>
|
||||
<Input
|
||||
value={formData.manager}
|
||||
onChange={(e) => setFormData({ ...formData, manager: e.target.value })}
|
||||
placeholder="Manager Name"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">Vendor #</Label>
|
||||
<Input
|
||||
value={formData.vendor_id}
|
||||
onChange={(e) => setFormData({ ...formData, vendor_id: e.target.value })}
|
||||
placeholder="Vendor #"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-right">
|
||||
<div className="mb-4">
|
||||
<Label className="text-xs font-semibold text-slate-700 block mb-2">Payment Terms</Label>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Badge
|
||||
className={`cursor-pointer transition-all ${formData.payment_terms === "30" ? "bg-blue-600 text-white" : "bg-white border-2 border-slate-200 text-slate-700 hover:border-blue-300"}`}
|
||||
onClick={() => setFormData({ ...formData, payment_terms: "30", due_date: format(addDays(new Date(formData.invoice_date), 30), 'yyyy-MM-dd') })}
|
||||
>
|
||||
30 days
|
||||
</Badge>
|
||||
<Badge
|
||||
className={`cursor-pointer transition-all ${formData.payment_terms === "45" ? "bg-blue-600 text-white" : "bg-white border-2 border-slate-200 text-slate-700 hover:border-blue-300"}`}
|
||||
onClick={() => setFormData({ ...formData, payment_terms: "45", due_date: format(addDays(new Date(formData.invoice_date), 45), 'yyyy-MM-dd') })}
|
||||
>
|
||||
45 days
|
||||
</Badge>
|
||||
<Badge
|
||||
className={`cursor-pointer transition-all ${formData.payment_terms === "60" ? "bg-blue-600 text-white" : "bg-white border-2 border-slate-200 text-slate-700 hover:border-blue-300"}`}
|
||||
onClick={() => setFormData({ ...formData, payment_terms: "60", due_date: format(addDays(new Date(formData.invoice_date), 60), 'yyyy-MM-dd') })}
|
||||
>
|
||||
60 days
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-500">Department:</span>
|
||||
<Input
|
||||
value={formData.department}
|
||||
onChange={(e) => setFormData({ ...formData, department: e.target.value })}
|
||||
placeholder="INV-G00G20242"
|
||||
className="h-8 w-48"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-500">PO#:</span>
|
||||
<Input
|
||||
value={formData.po_reference}
|
||||
onChange={(e) => setFormData({ ...formData, po_reference: e.target.value })}
|
||||
placeholder="INV-G00G20242"
|
||||
className="h-8 w-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* From and To */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 p-5 rounded-xl border border-blue-200">
|
||||
<h3 className="font-bold mb-4 flex items-center gap-2 text-blue-900">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white text-sm font-bold shadow-md">F</div>
|
||||
From (Vendor):
|
||||
</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<Input
|
||||
value={formData.from_company.name}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
from_company: { ...formData.from_company, name: e.target.value }
|
||||
})}
|
||||
className="font-semibold mb-2"
|
||||
/>
|
||||
<Input
|
||||
value={formData.from_company.address}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
from_company: { ...formData.from_company, address: e.target.value }
|
||||
})}
|
||||
className="text-sm"
|
||||
/>
|
||||
<Input
|
||||
value={formData.from_company.phone}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
from_company: { ...formData.from_company, phone: e.target.value }
|
||||
})}
|
||||
className="text-sm"
|
||||
/>
|
||||
<Input
|
||||
value={formData.from_company.email}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
from_company: { ...formData.from_company, email: e.target.value }
|
||||
})}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-slate-50 to-slate-100 p-5 rounded-xl border border-slate-200">
|
||||
<h3 className="font-bold mb-4 flex items-center gap-2 text-slate-900">
|
||||
<div className="w-8 h-8 bg-slate-600 rounded-lg flex items-center justify-center text-white text-sm font-bold shadow-md">T</div>
|
||||
To (Client):
|
||||
</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500 w-32">Company:</span>
|
||||
<Input
|
||||
value={formData.to_company.name}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
to_company: { ...formData.to_company, name: e.target.value }
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500 w-32">Phone:</span>
|
||||
<Input
|
||||
value={formData.to_company.phone}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
to_company: { ...formData.to_company, phone: e.target.value }
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500 w-32">Manager Name:</span>
|
||||
<Input
|
||||
value={formData.to_company.manager_name}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
to_company: { ...formData.to_company, manager_name: e.target.value }
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500 w-32">Email:</span>
|
||||
<Input
|
||||
value={formData.to_company.email}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
to_company: { ...formData.to_company, email: e.target.value }
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500 w-32">Hub Name:</span>
|
||||
<Input
|
||||
value={formData.to_company.hub_name}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
to_company: { ...formData.to_company, hub_name: e.target.value }
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500 w-32">Address:</span>
|
||||
<Input
|
||||
value={formData.to_company.address}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
to_company: { ...formData.to_company, address: e.target.value }
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500 w-32">Vendor #:</span>
|
||||
<Input
|
||||
value={formData.to_company.vendor_id}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
to_company: { ...formData.to_company, vendor_id: e.target.value }
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Staff Table */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4 p-4 bg-gradient-to-r from-blue-50 to-blue-100 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-lg">👥</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-blue-900">Staff Entries</h3>
|
||||
<p className="text-xs text-blue-700">{formData.staff_entries.length} entries</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleAddStaffEntry} className="bg-blue-600 hover:bg-blue-700 text-white shadow-md">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Add Staff Entry
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="p-2 text-left">#</th>
|
||||
<th className="p-2 text-left">Name</th>
|
||||
<th className="p-2 text-left">ClockIn</th>
|
||||
<th className="p-2 text-left">Lunch</th>
|
||||
<th className="p-2 text-left">Checkout</th>
|
||||
<th className="p-2 text-left">Worked H</th>
|
||||
<th className="p-2 text-left">Reg H</th>
|
||||
<th className="p-2 text-left">OT Hours</th>
|
||||
<th className="p-2 text-left">DT Hours</th>
|
||||
<th className="p-2 text-left">Rate</th>
|
||||
<th className="p-2 text-left">Reg Value</th>
|
||||
<th className="p-2 text-left">OT Value</th>
|
||||
<th className="p-2 text-left">DT Value</th>
|
||||
<th className="p-2 text-left">Total</th>
|
||||
<th className="p-2">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{formData.staff_entries.map((entry, idx) => (
|
||||
<tr key={idx} className="border-t hover:bg-slate-50">
|
||||
<td className="p-2">{idx + 1}</td>
|
||||
<td className="p-2">
|
||||
<Input
|
||||
value={entry.name}
|
||||
onChange={(e) => handleStaffChange(idx, 'name', e.target.value)}
|
||||
className="h-8 w-24"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Popover open={timePickerOpen === `checkin-${idx}`} onOpenChange={(open) => setTimePickerOpen(open ? `checkin-${idx}` : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 w-24 justify-start font-normal">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
{entry.check_in}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="01"
|
||||
max="12"
|
||||
value={selectedTime.hours}
|
||||
onChange={(e) => setSelectedTime({ ...selectedTime, hours: e.target.value.padStart(2, '0') })}
|
||||
className="w-16"
|
||||
placeholder="HH"
|
||||
/>
|
||||
<span className="text-2xl">:</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="00"
|
||||
max="59"
|
||||
value={selectedTime.minutes}
|
||||
onChange={(e) => setSelectedTime({ ...selectedTime, minutes: e.target.value.padStart(2, '0') })}
|
||||
className="w-16"
|
||||
placeholder="MM"
|
||||
/>
|
||||
<Select value={selectedTime.period} onValueChange={(val) => setSelectedTime({ ...selectedTime, period: val })}>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AM">AM</SelectItem>
|
||||
<SelectItem value="PM">PM</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => handleTimeSelect(idx, 'check_in')} className="w-full">
|
||||
Set Time
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={entry.lunch}
|
||||
onChange={(e) => handleStaffChange(idx, 'lunch', parseFloat(e.target.value))}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Popover open={timePickerOpen === `checkout-${idx}`} onOpenChange={(open) => setTimePickerOpen(open ? `checkout-${idx}` : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 w-24 justify-start font-normal">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
{entry.check_out || "hh:mm"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="01"
|
||||
max="12"
|
||||
value={selectedTime.hours}
|
||||
onChange={(e) => setSelectedTime({ ...selectedTime, hours: e.target.value.padStart(2, '0') })}
|
||||
className="w-16"
|
||||
placeholder="HH"
|
||||
/>
|
||||
<span className="text-2xl">:</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="00"
|
||||
max="59"
|
||||
value={selectedTime.minutes}
|
||||
onChange={(e) => setSelectedTime({ ...selectedTime, minutes: e.target.value.padStart(2, '0') })}
|
||||
className="w-16"
|
||||
placeholder="MM"
|
||||
/>
|
||||
<Select value={selectedTime.period} onValueChange={(val) => setSelectedTime({ ...selectedTime, period: val })}>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AM">AM</SelectItem>
|
||||
<SelectItem value="PM">PM</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => handleTimeSelect(idx, 'check_out')} className="w-full">
|
||||
Set Time
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={entry.worked_hours}
|
||||
onChange={(e) => handleStaffChange(idx, 'worked_hours', parseFloat(e.target.value))}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={entry.regular_hours}
|
||||
onChange={(e) => handleStaffChange(idx, 'regular_hours', parseFloat(e.target.value))}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={entry.ot_hours}
|
||||
onChange={(e) => handleStaffChange(idx, 'ot_hours', parseFloat(e.target.value))}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={entry.dt_hours}
|
||||
onChange={(e) => handleStaffChange(idx, 'dt_hours', parseFloat(e.target.value))}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={entry.rate}
|
||||
onChange={(e) => handleStaffChange(idx, 'rate', parseFloat(e.target.value))}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2 text-right">${entry.regular_value?.toFixed(2) || "0.00"}</td>
|
||||
<td className="p-2 text-right">${entry.ot_value?.toFixed(2) || "0.00"}</td>
|
||||
<td className="p-2 text-right">${entry.dt_value?.toFixed(2) || "0.00"}</td>
|
||||
<td className="p-2 text-right font-semibold">${entry.total?.toFixed(2) || "0.00"}</td>
|
||||
<td className="p-2 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveStaff(idx)}
|
||||
className="h-8 w-8 p-0 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charges */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4 p-4 bg-gradient-to-r from-green-50 to-emerald-100 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-emerald-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-lg">💰</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-emerald-900">Additional Charges</h3>
|
||||
<p className="text-xs text-emerald-700">{formData.charges.length} charges</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleAddCharge} className="bg-emerald-600 hover:bg-emerald-700 text-white shadow-md">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Add Charge
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="p-2 text-left">#</th>
|
||||
<th className="p-2 text-left">Name</th>
|
||||
<th className="p-2 text-left">QTY</th>
|
||||
<th className="p-2 text-left">Rate</th>
|
||||
<th className="p-2 text-left">Price</th>
|
||||
<th className="p-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{formData.charges.map((charge, idx) => (
|
||||
<tr key={idx} className="border-t hover:bg-slate-50">
|
||||
<td className="p-2">{idx + 1}</td>
|
||||
<td className="p-2">
|
||||
<Input
|
||||
value={charge.name}
|
||||
onChange={(e) => handleChargeChange(idx, 'name', e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={charge.qty}
|
||||
onChange={(e) => handleChargeChange(idx, 'qty', parseFloat(e.target.value))}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={charge.rate}
|
||||
onChange={(e) => handleChargeChange(idx, 'rate', parseFloat(e.target.value))}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">${charge.price?.toFixed(2) || "0.00"}</td>
|
||||
<td className="p-2 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveCharge(idx)}
|
||||
className="h-8 w-8 p-0 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<div className="w-96 bg-gradient-to-br from-blue-50 to-blue-100 p-6 rounded-xl border-2 border-blue-200 shadow-lg">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600">Sub total:</span>
|
||||
<span className="font-semibold text-slate-900">${totals.subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-slate-600">Other charges:</span>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.other_charges}
|
||||
onChange={(e) => setFormData({ ...formData, other_charges: e.target.value })}
|
||||
className="h-9 w-32 text-right border-blue-300 focus:border-blue-500 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xl font-bold pt-4 border-t-2 border-blue-300">
|
||||
<span className="text-blue-900">Grand total:</span>
|
||||
<span className="text-blue-900">${totals.grandTotal.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="mb-6">
|
||||
<Label className="mb-2 block">Notes</Label>
|
||||
<Textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
placeholder="Enter your notes here..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between items-center pt-6 border-t-2 border-blue-100">
|
||||
<Button variant="outline" onClick={() => navigate(createPageUrl('Invoices'))} className="border-slate-300">
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => saveMutation.mutate({ ...formData, status: "Draft" })}
|
||||
disabled={saveMutation.isPending}
|
||||
className="border-blue-300 text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
Save as Draft
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate(formData)}
|
||||
disabled={saveMutation.isPending}
|
||||
className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold px-8 shadow-lg"
|
||||
>
|
||||
{saveMutation.isPending ? "Saving..." : isEdit ? "Update Invoice" : "Create Invoice"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
546
frontend-web-free/src/pages/Invoices.jsx
Normal file
546
frontend-web-free/src/pages/Invoices.jsx
Normal file
@@ -0,0 +1,546 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { FileText, Plus, Search, Eye, AlertTriangle, CheckCircle, Clock, DollarSign, Edit, TrendingUp, TrendingDown, Calendar, ArrowUpRight, Sparkles, BarChart3, PieChart, MapPin, User } from "lucide-react";
|
||||
import { format, parseISO, isPast } from "date-fns";
|
||||
import PageHeader from "../components/common/PageHeader";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import AutoInvoiceGenerator from "../components/invoices/AutoInvoiceGenerator";
|
||||
import CreateInvoiceModal from "../components/invoices/CreateInvoiceModal";
|
||||
|
||||
const statusColors = {
|
||||
'Draft': 'bg-slate-100 text-slate-600 font-medium',
|
||||
'Open': 'bg-blue-100 text-blue-700 font-medium',
|
||||
'Pending Review': 'bg-blue-100 text-blue-700 font-medium',
|
||||
'Confirmed': 'bg-amber-100 text-amber-700 font-medium',
|
||||
'Approved': 'bg-emerald-100 text-emerald-700 font-medium',
|
||||
'Disputed': 'bg-red-100 text-red-700 font-medium',
|
||||
'Under Review': 'bg-orange-100 text-orange-700 font-medium',
|
||||
'Resolved': 'bg-cyan-100 text-cyan-700 font-medium',
|
||||
'Overdue': 'bg-red-100 text-red-700 font-medium',
|
||||
'Paid': 'bg-emerald-100 text-emerald-700 font-medium',
|
||||
'Reconciled': 'bg-purple-100 text-purple-700 font-medium',
|
||||
'Cancelled': 'bg-slate-100 text-slate-600 font-medium',
|
||||
};
|
||||
|
||||
export default function Invoices() {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-invoices'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: invoices = [], isLoading } = useQuery({
|
||||
queryKey: ['invoices'],
|
||||
queryFn: () => base44.entities.Invoice.list('-issue_date'),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const userRole = user?.user_role || user?.role;
|
||||
|
||||
// Auto-mark overdue invoices
|
||||
React.useEffect(() => {
|
||||
invoices.forEach(async (invoice) => {
|
||||
if (invoice.status === "Approved" && isPast(parseISO(invoice.due_date))) {
|
||||
try {
|
||||
await base44.entities.Invoice.update(invoice.id, { status: "Overdue" });
|
||||
} catch (error) {
|
||||
console.error('Failed to mark invoice as overdue:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [invoices]);
|
||||
|
||||
// Filter invoices based on user role
|
||||
const visibleInvoices = React.useMemo(() => {
|
||||
if (userRole === "client") {
|
||||
return invoices.filter(inv =>
|
||||
inv.business_name === user?.company_name ||
|
||||
inv.manager_name === user?.full_name ||
|
||||
inv.created_by === user?.email
|
||||
);
|
||||
}
|
||||
if (userRole === "vendor") {
|
||||
return invoices.filter(inv =>
|
||||
inv.vendor_name === user?.company_name ||
|
||||
inv.vendor_id === user?.vendor_id
|
||||
);
|
||||
}
|
||||
return invoices;
|
||||
}, [invoices, userRole, user]);
|
||||
|
||||
const getFilteredInvoices = () => {
|
||||
let filtered = visibleInvoices;
|
||||
|
||||
if (activeTab !== "all") {
|
||||
const statusMap = {
|
||||
'pending': 'Pending Review',
|
||||
'approved': 'Approved',
|
||||
'disputed': 'Disputed',
|
||||
'overdue': 'Overdue',
|
||||
'paid': 'Paid',
|
||||
'reconciled': 'Reconciled',
|
||||
};
|
||||
filtered = filtered.filter(inv => inv.status === statusMap[activeTab]);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(inv =>
|
||||
inv.invoice_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
inv.business_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
inv.manager_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
inv.event_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const filteredInvoices = getFilteredInvoices();
|
||||
|
||||
const getStatusCount = (status) => {
|
||||
if (status === "all") return visibleInvoices.length;
|
||||
return visibleInvoices.filter(inv => inv.status === status).length;
|
||||
};
|
||||
|
||||
const getTotalAmount = (status) => {
|
||||
const filtered = status === "all"
|
||||
? visibleInvoices
|
||||
: visibleInvoices.filter(inv => inv.status === status);
|
||||
return filtered.reduce((sum, inv) => sum + (inv.amount || 0), 0);
|
||||
};
|
||||
|
||||
const metrics = {
|
||||
all: getTotalAmount("all"),
|
||||
pending: getTotalAmount("Pending Review"),
|
||||
approved: getTotalAmount("Approved"),
|
||||
disputed: getTotalAmount("Disputed"),
|
||||
overdue: getTotalAmount("Overdue"),
|
||||
paid: getTotalAmount("Paid"),
|
||||
outstanding: getTotalAmount("Pending Review") + getTotalAmount("Approved") + getTotalAmount("Overdue"),
|
||||
};
|
||||
|
||||
// Smart Insights
|
||||
const insights = React.useMemo(() => {
|
||||
const currentMonth = visibleInvoices.filter(inv => {
|
||||
const issueDate = parseISO(inv.issue_date);
|
||||
const now = new Date();
|
||||
return issueDate.getMonth() === now.getMonth() && issueDate.getFullYear() === now.getFullYear();
|
||||
});
|
||||
|
||||
const lastMonth = visibleInvoices.filter(inv => {
|
||||
const issueDate = parseISO(inv.issue_date);
|
||||
const now = new Date();
|
||||
const lastMonthDate = new Date(now.getFullYear(), now.getMonth() - 1);
|
||||
return issueDate.getMonth() === lastMonthDate.getMonth() && issueDate.getFullYear() === lastMonthDate.getFullYear();
|
||||
});
|
||||
|
||||
const currentTotal = currentMonth.reduce((sum, inv) => sum + (inv.amount || 0), 0);
|
||||
const lastTotal = lastMonth.reduce((sum, inv) => sum + (inv.amount || 0), 0);
|
||||
const percentChange = lastTotal > 0 ? ((currentTotal - lastTotal) / lastTotal * 100).toFixed(1) : 0;
|
||||
|
||||
const avgPaymentTime = visibleInvoices
|
||||
.filter(inv => inv.status === "Paid" && inv.paid_date && inv.issue_date)
|
||||
.map(inv => {
|
||||
const days = Math.floor((parseISO(inv.paid_date) - parseISO(inv.issue_date)) / (1000 * 60 * 60 * 24));
|
||||
return days;
|
||||
});
|
||||
const avgDays = avgPaymentTime.length > 0 ? Math.round(avgPaymentTime.reduce((a, b) => a + b, 0) / avgPaymentTime.length) : 0;
|
||||
|
||||
const onTimePayments = visibleInvoices.filter(inv =>
|
||||
inv.status === "Paid" && inv.paid_date && inv.due_date && parseISO(inv.paid_date) <= parseISO(inv.due_date)
|
||||
).length;
|
||||
const totalPaid = visibleInvoices.filter(inv => inv.status === "Paid").length;
|
||||
const onTimeRate = totalPaid > 0 ? ((onTimePayments / totalPaid) * 100).toFixed(0) : 0;
|
||||
|
||||
const topClient = Object.entries(
|
||||
visibleInvoices.reduce((acc, inv) => {
|
||||
const client = inv.business_name || "Unknown";
|
||||
acc[client] = (acc[client] || 0) + (inv.amount || 0);
|
||||
return acc;
|
||||
}, {})
|
||||
).sort((a, b) => b[1] - a[1])[0];
|
||||
|
||||
// For clients: calculate best hub by reconciliation rate
|
||||
const bestHub = userRole === "client" ? (() => {
|
||||
const hubStats = visibleInvoices.reduce((acc, inv) => {
|
||||
const hub = inv.hub || "Unknown";
|
||||
if (!acc[hub]) {
|
||||
acc[hub] = { total: 0, reconciled: 0, paid: 0 };
|
||||
}
|
||||
acc[hub].total++;
|
||||
if (inv.status === "Reconciled") acc[hub].reconciled++;
|
||||
if (inv.status === "Paid" || inv.status === "Reconciled") acc[hub].paid++;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const sortedHubs = Object.entries(hubStats)
|
||||
.map(([hub, stats]) => ({
|
||||
hub,
|
||||
rate: stats.total > 0 ? ((stats.paid / stats.total) * 100).toFixed(0) : 0,
|
||||
total: stats.total
|
||||
}))
|
||||
.sort((a, b) => b.rate - a.rate);
|
||||
|
||||
return sortedHubs[0] || null;
|
||||
})() : null;
|
||||
|
||||
return {
|
||||
percentChange,
|
||||
isGrowth: percentChange > 0,
|
||||
avgDays,
|
||||
onTimeRate,
|
||||
topClient: topClient ? { name: topClient[0], amount: topClient[1] } : null,
|
||||
bestHub,
|
||||
currentMonthCount: currentMonth.length,
|
||||
currentTotal,
|
||||
};
|
||||
}, [visibleInvoices, userRole]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutoInvoiceGenerator />
|
||||
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-[1600px] mx-auto">
|
||||
<PageHeader
|
||||
title="Invoices"
|
||||
subtitle={`${filteredInvoices.length} invoices • $${metrics.all.toLocaleString()} total`}
|
||||
actions={
|
||||
userRole === "vendor" && (
|
||||
<Button onClick={() => setShowCreateModal(true)} className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Create Invoice
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Alert Banners */}
|
||||
{metrics.disputed > 0 && (
|
||||
<div className="mb-6 p-4 bg-red-50 border-l-4 border-red-500 rounded-lg flex items-center gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
<div>
|
||||
<p className="font-semibold text-red-900">Disputed Invoices Require Attention</p>
|
||||
<p className="text-sm text-red-700">{getStatusCount("Disputed")} invoices are currently disputed</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics.overdue > 0 && userRole === "client" && (
|
||||
<div className="mb-6 p-4 bg-amber-50 border-l-4 border-amber-500 rounded-lg flex items-center gap-3">
|
||||
<Clock className="w-5 h-5 text-amber-600" />
|
||||
<div>
|
||||
<p className="font-semibold text-amber-900">Overdue Payments</p>
|
||||
<p className="text-sm text-amber-700">${metrics.overdue.toLocaleString()} in overdue invoices</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
|
||||
<TabsList className="bg-slate-100 border border-slate-200 h-auto p-1.5 flex-wrap gap-1">
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
All
|
||||
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("all")}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="pending"
|
||||
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
|
||||
>
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
Pending
|
||||
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Pending Review")}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="approved"
|
||||
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Approved
|
||||
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Approved")}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="disputed"
|
||||
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 mr-2" />
|
||||
Disputed
|
||||
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Disputed")}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="overdue"
|
||||
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 mr-2" />
|
||||
Overdue
|
||||
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Overdue")}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="paid"
|
||||
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Paid
|
||||
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Paid")}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="reconciled"
|
||||
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Reconciled
|
||||
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Reconciled")}</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* Metric Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card className="border-0 bg-blue-50 shadow-sm hover:shadow-md transition-all">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-blue-600 uppercase tracking-wider font-semibold mb-0.5">Total Value</p>
|
||||
<p className="text-2xl font-bold text-blue-700">${metrics.all.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-amber-50 shadow-sm hover:shadow-md transition-all">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-amber-500 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<DollarSign className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-amber-600 uppercase tracking-wider font-semibold mb-0.5">Outstanding</p>
|
||||
<p className="text-2xl font-bold text-amber-700">${metrics.outstanding.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-red-50 shadow-sm hover:shadow-md transition-all">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-red-500 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<AlertTriangle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-red-600 uppercase tracking-wider font-semibold mb-0.5">Disputed</p>
|
||||
<p className="text-2xl font-bold text-red-700">${metrics.disputed.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-emerald-50 shadow-sm hover:shadow-md transition-all">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-emerald-500 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<CheckCircle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-emerald-600 uppercase tracking-wider font-semibold mb-0.5">Paid</p>
|
||||
<p className="text-2xl font-bold text-emerald-700">${metrics.paid.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Smart Insights Banner */}
|
||||
<div className="mb-6 bg-slate-100 rounded-2xl p-6 shadow-sm border border-slate-200">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-amber-500 rounded-xl flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-900">Smart Insights</h3>
|
||||
<p className="text-sm text-slate-500">AI-powered analysis of your invoice performance</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl p-4 border border-slate-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-slate-500">This Month</span>
|
||||
<div className={`flex items-center gap-1 ${insights.isGrowth ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
{insights.isGrowth ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
|
||||
<span className="text-xs font-bold">{insights.percentChange}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-slate-900">${insights.currentTotal.toLocaleString()}</p>
|
||||
<p className="text-xs text-slate-400 mt-1">{insights.currentMonthCount} invoices</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 border border-slate-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-slate-500">Avg. Payment Time</span>
|
||||
<Calendar className="w-4 h-4 text-slate-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-slate-900">{insights.avgDays} days</p>
|
||||
<p className="text-xs text-slate-400 mt-1">From issue to payment</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 border border-slate-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-slate-500">On-Time Rate</span>
|
||||
<CheckCircle className="w-4 h-4 text-slate-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-slate-900">{insights.onTimeRate}%</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Paid before due date</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 border border-slate-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-slate-500">
|
||||
{userRole === "client" ? "Best Hub" : "Top Client"}
|
||||
</span>
|
||||
<ArrowUpRight className="w-4 h-4 text-slate-400" />
|
||||
</div>
|
||||
{userRole === "client" ? (
|
||||
<>
|
||||
<p className="text-lg font-bold text-slate-900 truncate">{insights.bestHub?.hub || "—"}</p>
|
||||
<p className="text-xs text-slate-400 mt-1">{insights.bestHub?.rate || 0}% on-time</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-lg font-bold text-slate-900 truncate">{insights.topClient?.name || "—"}</p>
|
||||
<p className="text-xs text-slate-400 mt-1">${insights.topClient?.amount.toLocaleString() || 0}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="bg-white rounded-lg p-4 mb-6 border border-slate-200">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search by invoice number, client, event..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoices Table */}
|
||||
<Card className="border-slate-200 shadow-lg">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 hover:bg-slate-50">
|
||||
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Invoice #</TableHead>
|
||||
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Hub</TableHead>
|
||||
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Event</TableHead>
|
||||
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Manager</TableHead>
|
||||
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Date & Time</TableHead>
|
||||
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Amount</TableHead>
|
||||
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Status</TableHead>
|
||||
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredInvoices.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-12 text-slate-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
||||
<p className="font-medium">No invoices found</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredInvoices.map((invoice) => {
|
||||
const invoiceDate = parseISO(invoice.issue_date);
|
||||
const dayOfWeek = format(invoiceDate, 'EEEE');
|
||||
const dateFormatted = format(invoiceDate, 'MM.dd.yy');
|
||||
|
||||
return (
|
||||
<TableRow key={invoice.id} className="hover:bg-slate-50 transition-all border-b border-slate-100">
|
||||
<TableCell className="font-bold text-slate-900">{invoice.invoice_number}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-purple-600" />
|
||||
<span className="text-slate-900 font-medium">{invoice.hub || "—"}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-900 font-medium">{invoice.event_name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-slate-700">{invoice.manager_name || invoice.created_by || "—"}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-slate-900 font-medium">{dateFormatted}</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{dayOfWeek}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<DollarSign className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-slate-900">${invoice.amount?.toLocaleString()}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${statusColors[invoice.status]} px-3 py-1 rounded-md text-xs`}>
|
||||
{invoice.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(createPageUrl(`InvoiceDetail?id=${invoice.id}`))}
|
||||
className="font-semibold hover:bg-blue-50 hover:text-[#0A39DF]"
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
View
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateInvoiceModal
|
||||
open={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
520
frontend-web-free/src/pages/Layout.jsx
Normal file
520
frontend-web-free/src/pages/Layout.jsx
Normal file
@@ -0,0 +1,520 @@
|
||||
|
||||
import React from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Users, LayoutDashboard, UserPlus, Calendar, Briefcase, FileText,
|
||||
DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare,
|
||||
Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff,
|
||||
RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical,
|
||||
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap, ArrowLeft
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
import ChatBubble from "@/components/chat/ChatBubble";
|
||||
import RoleSwitcher from "@/components/dev/RoleSwitcher";
|
||||
import NotificationPanel from "@/components/notifications/NotificationPanel";
|
||||
import { NotificationEngine } from "@/components/notifications/NotificationEngine";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
// Navigation items for each role
|
||||
const roleNavigationMap = {
|
||||
admin: [
|
||||
{ title: "Home", url: createPageUrl("Dashboard"), icon: LayoutDashboard },
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||
{ title: "Schedule", url: createPageUrl("Schedule"), icon: Calendar },
|
||||
{ title: "Staff Availability", url: createPageUrl("StaffAvailability"), icon: Users },
|
||||
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
|
||||
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||
{ title: "Onboarding", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||
{ title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign },
|
||||
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
|
||||
{ title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
{ title: "User Management", url: createPageUrl("UserManagement"), icon: Users },
|
||||
{ title: "Permissions", url: createPageUrl("Permissions"), icon: Shield },
|
||||
{ title: "Settings", url: createPageUrl("Settings"), icon: SettingsIcon },
|
||||
{ title: "Activity Log", url: createPageUrl("ActivityLog"), icon: Activity },
|
||||
],
|
||||
procurement: [
|
||||
{ title: "Home", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
|
||||
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
{ title: "Compliance", url: createPageUrl("WorkforceCompliance"), icon: Shield },
|
||||
{ title: "Rate Matrix", url: createPageUrl("VendorRateCard"), icon: DollarSign },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
],
|
||||
operator: [
|
||||
{ title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
|
||||
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
],
|
||||
sector: [
|
||||
{ title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
|
||||
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
],
|
||||
client: [
|
||||
{ title: "Home", url: createPageUrl("ClientDashboard"), icon: LayoutDashboard },
|
||||
{ title: "My Orders", url: createPageUrl("ClientOrders"), icon: Clipboard },
|
||||
{ title: "New Order", url: createPageUrl("CreateEvent"), icon: UserPlus },
|
||||
{ title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store },
|
||||
{ title: "Compare Rates", url: createPageUrl("VendorRateCard"), icon: DollarSign },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||
{ title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
{ title: "Support", url: createPageUrl("Support"), icon: HelpCircle },
|
||||
],
|
||||
vendor: [
|
||||
{ title: "Home", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
|
||||
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
|
||||
{ title: "Schedule", url: createPageUrl("Schedule"), icon: Calendar },
|
||||
{ title: "Staff Availability", url: createPageUrl("StaffAvailability"), icon: Users },
|
||||
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
|
||||
{ title: "Team", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
{ title: "Compliance", url: createPageUrl("VendorCompliance"), icon: Shield },
|
||||
{ title: "Communications", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
{ title: "Leads", url: createPageUrl("Business"), icon: UserCheck },
|
||||
{ title: "Business", url: createPageUrl("Business"), icon: Briefcase },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
{ title: "Audit Trail", url: createPageUrl("ActivityLog"), icon: Activity },
|
||||
{ title: "Performance", url: createPageUrl("VendorPerformance"), icon: TrendingUp },
|
||||
],
|
||||
workforce: [
|
||||
{ title: "Home", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Shift Requests", url: createPageUrl("WorkerShiftProposals"), icon: Calendar },
|
||||
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
|
||||
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
|
||||
{ title: "Earnings", url: createPageUrl("WorkforceEarnings"), icon: DollarSign },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
{ title: "Profile", url: createPageUrl("WorkforceProfile"), icon: Users },
|
||||
],
|
||||
};
|
||||
|
||||
const getRoleName = (role) => {
|
||||
const names = {
|
||||
admin: "KROW Admin",
|
||||
procurement: "Procurement Manager",
|
||||
operator: "Operator",
|
||||
sector: "Sector Manager",
|
||||
client: "Client",
|
||||
vendor: "Vendor Partner",
|
||||
workforce: "Staff Member"
|
||||
};
|
||||
return names[role] || "User";
|
||||
};
|
||||
|
||||
const getRoleDescription = (role) => {
|
||||
const descriptions = {
|
||||
admin: "Platform Administrator",
|
||||
procurement: "Connects Operators, Sectors & Vendors",
|
||||
operator: "Manages Multiple Sectors",
|
||||
sector: "Branch Location Manager",
|
||||
client: "Service Requester",
|
||||
vendor: "Workforce Provider",
|
||||
workforce: "Service Professional"
|
||||
};
|
||||
return descriptions[role] || "";
|
||||
};
|
||||
|
||||
const getDashboardUrl = (role) => {
|
||||
const dashboardMap = {
|
||||
admin: "Dashboard",
|
||||
procurement: "ProcurementDashboard",
|
||||
operator: "OperatorDashboard",
|
||||
sector: "OperatorDashboard",
|
||||
client: "ClientDashboard",
|
||||
vendor: "VendorDashboard",
|
||||
workforce: "WorkforceDashboard"
|
||||
};
|
||||
return createPageUrl(dashboardMap[role] || "Dashboard");
|
||||
};
|
||||
|
||||
const getLayerName = (role) => {
|
||||
const layers = {
|
||||
admin: "KROW Admin",
|
||||
procurement: "Procurement",
|
||||
operator: "Operator",
|
||||
sector: "Sector",
|
||||
client: "Client",
|
||||
vendor: "Vendor",
|
||||
workforce: "Workforce"
|
||||
};
|
||||
return layers[role] || "User";
|
||||
};
|
||||
|
||||
const getLayerColor = (role) => {
|
||||
const colors = {
|
||||
admin: "from-red-500 to-red-700",
|
||||
procurement: "from-[#0A39DF] to-[#1C323E]",
|
||||
operator: "from-emerald-500 to-emerald-700",
|
||||
sector: "from-purple-500 to-purple-700",
|
||||
client: "from-green-500 to-green-700",
|
||||
vendor: "from-amber-500 to-amber-700",
|
||||
workforce: "from-slate-500 to-slate-700"
|
||||
};
|
||||
return colors[role] || "from-blue-500 to-blue-700";
|
||||
};
|
||||
|
||||
function NavigationMenu({ location, userRole, closeSheet }) {
|
||||
const navigationItems = roleNavigationMap[userRole] || roleNavigationMap.admin;
|
||||
|
||||
return (
|
||||
<nav className="space-y-1">
|
||||
<div className="px-4 py-2 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Main Menu
|
||||
</div>
|
||||
{navigationItems.map((item) => {
|
||||
const isActive = location.pathname === item.url;
|
||||
return (
|
||||
<Link
|
||||
key={item.title}
|
||||
to={item.url}
|
||||
onClick={closeSheet}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-[#0A39DF] text-white shadow-md font-medium'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-[#1C323E]'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="text-sm">{item.title}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [showNotifications, setShowNotifications] = React.useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-layout'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const sampleAvatar = "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=400&fit=crop";
|
||||
const userAvatar = user?.profile_picture || sampleAvatar;
|
||||
|
||||
const { data: unreadCount = 0 } = useQuery({
|
||||
queryKey: ['unread-notifications', user?.id],
|
||||
queryFn: async () => {
|
||||
if (!user?.id) return 0;
|
||||
|
||||
const notifications = await base44.entities.ActivityLog.filter({
|
||||
userId: user?.id,
|
||||
isRead: false
|
||||
});
|
||||
return notifications.length;
|
||||
},
|
||||
enabled: !!user?.id,
|
||||
initialData: 0,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const userRole = user?.user_role || user?.role || "admin";
|
||||
const userName = user?.full_name || user?.email || "User";
|
||||
const userInitial = userName.charAt(0).toUpperCase();
|
||||
|
||||
const handleLogout = () => {
|
||||
base44.auth.logout();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col w-full bg-slate-50">
|
||||
<style>{`
|
||||
:root {
|
||||
--primary: 10 57 223;
|
||||
--primary-foreground: 255 255 255;
|
||||
--secondary: 200 219 220;
|
||||
--accent: 28 50 62;
|
||||
--muted: 241 245 249;
|
||||
}
|
||||
|
||||
.rdp * { border-color: transparent !important; }
|
||||
.rdp-day { font-size: 0.875rem !important; min-width: 36px !important; height: 36px !important; border-radius: 50% !important; transition: all 0.2s ease !important; font-weight: 500 !important; position: relative !important; }
|
||||
.rdp-day button { width: 100% !important; height: 100% !important; border-radius: 50% !important; background-color: transparent !important; }
|
||||
.rdp-day_range_start, .rdp-day_range_start > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
|
||||
.rdp-day_range_end, .rdp-day_range_end > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
|
||||
.rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end), .rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end) > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
|
||||
.rdp-day_selected, .rdp-day_selected > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
|
||||
.rdp-day_range_middle, .rdp-day_range_middle > button { background-color: #dbeafe !important; background: #dbeafe !important; color: #2563eb !important; font-weight: 600 !important; border-radius: 0 !important; box-shadow: none !important; }
|
||||
.rdp-day_range_start.rdp-day_range_end, .rdp-day_range_start.rdp-day_range_end > button { border-radius: 50% !important; background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; }
|
||||
.rdp-day:hover:not(.rdp-day_selected):not(.rdp-day_disabled):not(.rdp-day_range_start):not(.rdp-day_range_end):not(.rdp-day_range_middle) > button { background-color: #eff6ff !important; background: #eff6ff !important; color: #2563eb !important; border-radius: 50% !important; }
|
||||
.rdp-day_today:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::after { content: '' !important; position: absolute !important; bottom: 4px !important; left: 50% !important; transform: translateX(-50%) !important; width: 4px !important; height: 4px !important; background-color: #ec4899 !important; border-radius: 50% !important; z-index: 10 !important; }
|
||||
.rdp-day_today.rdp-day_selected, .rdp-day_today.rdp-day_range_start, .rdp-day_today.rdp-day_range_end { color: white !important; }
|
||||
.rdp-day_today.rdp-day_selected > button, .rdp-day_today.rdp-day_range_start > button, .rdp-day_today.rdp-day_range_end > button { color: white !important; }
|
||||
.rdp-day_outside, .rdp-day_outside > button { color: #cbd5e1 !important; opacity: 0.5 !important; }
|
||||
.rdp-day_disabled, .rdp-day_disabled > button { opacity: 0.3 !important; cursor: not-allowed !important; }
|
||||
.rdp-day_selected, .rdp-day_range_start, .rdp-day_range_end, .rdp-day_range_middle { opacity: 1 !important; visibility: visible !important; z-index: 5 !important; }
|
||||
.rdp-day.has-events:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::before { content: '' !important; position: absolute !important; top: 4px !important; right: 4px !important; width: 4px !important; height: 4px !important; background-color: #2563eb !important; border-radius: 50% !important; }
|
||||
.rdp-day_selected.has-events::before, .rdp-day_range_start.has-events::before, .rdp-day_range_end.has-events::before { background-color: white !important; }
|
||||
.rdp-day_range_middle.has-events::before { background-color: #2563eb !important; }
|
||||
.rdp-head_cell { color: #64748b !important; font-weight: 600 !important; font-size: 0.75rem !important; text-transform: uppercase !important; padding: 8px 0 !important; }
|
||||
.rdp-caption_label { font-size: 1rem !important; font-weight: 700 !important; color: #0f172a !important; }
|
||||
.rdp-nav_button { width: 32px !important; height: 32px !important; border-radius: 6px !important; transition: all 0.2s ease !important; }
|
||||
.rdp-nav_button:hover { background-color: #eff6ff !important; color: #2563eb !important; }
|
||||
.rdp-months { gap: 2rem !important; }
|
||||
.rdp-month { padding: 0.75rem !important; }
|
||||
.rdp-table { border-spacing: 0 !important; margin-top: 1rem !important; }
|
||||
.rdp-cell { padding: 2px !important; }
|
||||
.rdp-day[style*="background"] { background: transparent !important; }
|
||||
`}</style>
|
||||
|
||||
<header className="bg-white border-b border-slate-200 shadow-sm sticky top-0 z-30">
|
||||
<div className="px-4 md:px-6 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(-1)}
|
||||
className="hover:bg-slate-100"
|
||||
title="Go back"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-slate-600" />
|
||||
</Button>
|
||||
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="lg:hidden hover:bg-slate-100">
|
||||
<Menu className="w-5 h-5 text-[#1C323E]" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-64 p-0 bg-white border-slate-200">
|
||||
<div className="border-b border-slate-200 p-6">
|
||||
<Link to={getDashboardUrl(userRole)} className="flex items-center gap-3 mb-4" onClick={() => setMobileMenuOpen(false)}>
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
<img src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png" alt="KROW Logo" className="w-full h-full object-contain" />
|
||||
</div>
|
||||
<h2 className="font-bold text-[#1C323E]">KROW</h2>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 bg-slate-50 p-3 rounded-lg">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={userAvatar} alt={userName} />
|
||||
<AvatarFallback className="bg-[#0A39DF] text-white font-bold">{userInitial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-[#1C323E] text-sm truncate">{userName}</p>
|
||||
<p className="text-xs text-slate-500 truncate">{getRoleName(userRole)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<NavigationMenu location={location} userRole={userRole} closeSheet={() => setMobileMenuOpen(false)} />
|
||||
</div>
|
||||
<div className="p-3 border-t border-slate-200">
|
||||
<Button variant="ghost" className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {handleLogout(); setMobileMenuOpen(false);}}>
|
||||
<LogOut className="w-4 h-4 mr-2" />Logout
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Link to={getDashboardUrl(userRole)} className="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
<img src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png" alt="KROW Logo" className="w-full h-full object-contain" />
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<h1 className="text-base font-bold text-[#1C323E]">KROW Workforce Control Tower</h1>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex flex-1 max-w-xl">
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input type="text" placeholder="Find employees, menu items, settings, and more..." className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#0A39DF] focus:border-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handleRefresh} className="flex items-center gap-2 px-3 py-2 text-slate-500 hover:text-[#0A39DF] hover:bg-blue-50 rounded-lg transition-all group" title="Unpublished changes - Click to refresh">
|
||||
<CloudOff className="w-5 h-5 group-hover:animate-pulse" />
|
||||
<span className="hidden lg:inline text-sm font-medium">Unpublished changes</span>
|
||||
</button>
|
||||
|
||||
<Button variant="ghost" size="icon" className="md:hidden hover:bg-slate-100" title="Search">
|
||||
<Search className="w-5 h-5 text-slate-600" />
|
||||
</Button>
|
||||
|
||||
<button onClick={() => setShowNotifications(true)} className="relative p-2 hover:bg-slate-100 rounded-lg transition-colors" title="Notifications">
|
||||
<Bell className="w-5 h-5 text-slate-600" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Link to={getDashboardUrl(userRole)} title="Home">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-slate-100">
|
||||
<Home className="w-5 h-5 text-slate-600" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={createPageUrl("Messages")} title="Messages">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-slate-100">
|
||||
<MessageSquare className="w-5 h-5 text-slate-600" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={createPageUrl("Support")} title="Help & Support">
|
||||
<Button variant="ghost" size="icon" className="hover:bg-slate-100">
|
||||
<HelpCircle className="w-5 h-5 text-slate-600" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="hover:bg-slate-100" title="More options">
|
||||
<MoreVertical className="w-5 h-5 text-slate-600" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("NotificationSettings")}>
|
||||
<Bell className="w-4 h-4 mr-2" />Notification Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Settings")}>
|
||||
<SettingsIcon className="w-4 h-4 mr-2" />Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Reports")}>
|
||||
<FileText className="w-4 h-4 mr-2" />Reports
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("ActivityLog")}>
|
||||
<Activity className="w-4 h-4 mr-2" />Activity Log
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-red-600">
|
||||
<LogOut className="w-4 h-4 mr-2" />Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-2 hover:bg-slate-100 rounded-lg p-1.5 transition-colors" title={`${userName} - ${getRoleName(userRole)}`}>
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarImage src={userAvatar} alt={userName} />
|
||||
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white font-bold text-sm">{userInitial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden lg:block text-sm font-medium text-slate-700">{userName.split(' ')[0]}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>
|
||||
<div>
|
||||
<p className="font-semibold">{userName}</p>
|
||||
<p className="text-xs text-slate-500">{user?.email}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{getRoleName(userRole)}</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => window.location.href = getDashboardUrl(userRole)}>
|
||||
<Home className="w-4 h-4 mr-2" />Dashboard
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("WorkforceProfile")}>
|
||||
<User className="w-4 h-4 mr-2" />My Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<aside className="hidden lg:flex lg:flex-col w-64 bg-white border-r border-slate-200 shadow-sm overflow-y-auto">
|
||||
<div className="p-3">
|
||||
<NavigationMenu location={location} userRole={userRole} closeSheet={() => {}} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 overflow-auto pb-16">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-0 left-0 right-0 z-20 bg-white border-t-2 border-slate-200 shadow-lg">
|
||||
<div className="px-4 py-2 flex items-center justify-center gap-3">
|
||||
<span className="text-xs text-slate-500 font-medium">Current:</span>
|
||||
<div className={`px-4 py-1.5 rounded-full bg-gradient-to-r ${getLayerColor(userRole)} text-white font-bold text-sm shadow-md`}>
|
||||
{getLayerName(userRole)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NotificationPanel isOpen={showNotifications} onClose={() => setShowNotifications(false)} />
|
||||
<NotificationEngine />
|
||||
<ChatBubble />
|
||||
<RoleSwitcher />
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
frontend-web-free/src/pages/Login.jsx
Normal file
86
frontend-web-free/src/pages/Login.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { signInWithEmailAndPassword } from "firebase/auth";
|
||||
import { auth } from "@/firebase";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!email || !password) {
|
||||
setError("Email and password are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await signInWithEmailAndPassword(auth, email, password);
|
||||
navigate("/");
|
||||
} catch (error) {
|
||||
setError("Invalid credentials. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<Card className="w-[350px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Login</CardTitle>
|
||||
<CardDescription>Enter your credentials to access your account.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLogin}>
|
||||
<div className="grid w-full items-center gap-4">
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col">
|
||||
<Button onClick={handleLogin} disabled={loading} className="w-full">
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Login"}
|
||||
</Button>
|
||||
<p className="mt-4 text-sm text-slate-600">
|
||||
Don't have an account?{" "}
|
||||
<Link to="/register" className="text-blue-600 hover:underline">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
524
frontend-web-free/src/pages/Messages.jsx
Normal file
524
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
271
frontend-web-free/src/pages/NotificationSettings.jsx
Normal file
271
frontend-web-free/src/pages/NotificationSettings.jsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Bell, Mail, Calendar, Briefcase, AlertCircle, CheckCircle } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default function NotificationSettings() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: currentUser } = useQuery({
|
||||
queryKey: ['current-user-notification-settings'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const [preferences, setPreferences] = useState(
|
||||
currentUser?.notification_preferences || {
|
||||
email_notifications: true,
|
||||
in_app_notifications: true,
|
||||
shift_assignments: true,
|
||||
shift_reminders: true,
|
||||
shift_changes: true,
|
||||
upcoming_events: true,
|
||||
new_leads: true,
|
||||
invoice_updates: true,
|
||||
system_alerts: true,
|
||||
}
|
||||
);
|
||||
|
||||
const updatePreferencesMutation = useMutation({
|
||||
mutationFn: (prefs) => base44.auth.updateMe({ notification_preferences: prefs }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['current-user-notification-settings'] });
|
||||
toast({
|
||||
title: "✅ Settings Updated",
|
||||
description: "Your notification preferences have been saved",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "❌ Update Failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggle = (key) => {
|
||||
setPreferences(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updatePreferencesMutation.mutate(preferences);
|
||||
};
|
||||
|
||||
const userRole = currentUser?.role || currentUser?.user_role || 'admin';
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Notification Settings</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Configure how and when you receive notifications
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Global Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="w-5 h-5" />
|
||||
Global Notification Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<Label className="font-semibold">In-App Notifications</Label>
|
||||
<p className="text-sm text-slate-500">Receive notifications in the app</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.in_app_notifications}
|
||||
onCheckedChange={() => handleToggle('in_app_notifications')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="w-5 h-5 text-purple-600" />
|
||||
<div>
|
||||
<Label className="font-semibold">Email Notifications</Label>
|
||||
<p className="text-sm text-slate-500">Receive notifications via email</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.email_notifications}
|
||||
onCheckedChange={() => handleToggle('email_notifications')}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Staff/Workforce Notifications */}
|
||||
{(userRole === 'workforce' || userRole === 'admin' || userRole === 'vendor') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5" />
|
||||
Shift Notifications
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||
<div>
|
||||
<Label className="font-semibold">Shift Assignments</Label>
|
||||
<p className="text-sm text-slate-500">When you're assigned to a new shift</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.shift_assignments}
|
||||
onCheckedChange={() => handleToggle('shift_assignments')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||
<div>
|
||||
<Label className="font-semibold">Shift Reminders</Label>
|
||||
<p className="text-sm text-slate-500">24 hours before your shift starts</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.shift_reminders}
|
||||
onCheckedChange={() => handleToggle('shift_reminders')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||
<div>
|
||||
<Label className="font-semibold">Shift Changes</Label>
|
||||
<p className="text-sm text-slate-500">When shift details are modified</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.shift_changes}
|
||||
onCheckedChange={() => handleToggle('shift_changes')}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Client Notifications */}
|
||||
{(userRole === 'client' || userRole === 'admin') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Briefcase className="w-5 h-5" />
|
||||
Event Notifications
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||
<div>
|
||||
<Label className="font-semibold">Upcoming Events</Label>
|
||||
<p className="text-sm text-slate-500">Reminders 3 days before your event</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.upcoming_events}
|
||||
onCheckedChange={() => handleToggle('upcoming_events')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||
<div>
|
||||
<Label className="font-semibold">Staff Updates</Label>
|
||||
<p className="text-sm text-slate-500">When staff are assigned or changed</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.shift_changes}
|
||||
onCheckedChange={() => handleToggle('shift_changes')}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Vendor Notifications */}
|
||||
{(userRole === 'vendor' || userRole === 'admin') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Briefcase className="w-5 h-5" />
|
||||
Business Notifications
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||
<div>
|
||||
<Label className="font-semibold">New Leads</Label>
|
||||
<p className="text-sm text-slate-500">When new staffing opportunities are available</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.new_leads}
|
||||
onCheckedChange={() => handleToggle('new_leads')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||
<div>
|
||||
<Label className="font-semibold">Invoice Updates</Label>
|
||||
<p className="text-sm text-slate-500">Invoice status changes and payments</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.invoice_updates}
|
||||
onCheckedChange={() => handleToggle('invoice_updates')}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* System Notifications */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
System Notifications
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between p-3 hover:bg-slate-50 rounded-lg">
|
||||
<div>
|
||||
<Label className="font-semibold">System Alerts</Label>
|
||||
<p className="text-sm text-slate-500">Important platform updates and announcements</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.system_alerts}
|
||||
onCheckedChange={() => handleToggle('system_alerts')}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPreferences(currentUser?.notification_preferences || {})}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={updatePreferencesMutation.isPending}
|
||||
className="bg-[#0A39DF]"
|
||||
>
|
||||
{updatePreferencesMutation.isPending ? "Saving..." : "Save Preferences"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
507
frontend-web-free/src/pages/Onboarding.jsx
Normal file
507
frontend-web-free/src/pages/Onboarding.jsx
Normal file
@@ -0,0 +1,507 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CheckCircle2, UserPlus, User, Lock, Briefcase } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
export default function Onboarding() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const inviteCode = urlParams.get('invite');
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
title: "",
|
||||
department: "",
|
||||
hub: "",
|
||||
password: "",
|
||||
confirmPassword: ""
|
||||
});
|
||||
|
||||
// Fetch invite details if invite code exists
|
||||
const { data: invite } = useQuery({
|
||||
queryKey: ['team-invite', inviteCode],
|
||||
queryFn: async () => {
|
||||
const allInvites = await base44.entities.TeamMemberInvite.list();
|
||||
const foundInvite = allInvites.find(inv => inv.invite_code === inviteCode && inv.invite_status === 'pending');
|
||||
|
||||
if (foundInvite) {
|
||||
// Pre-fill form with invite data
|
||||
const nameParts = (foundInvite.full_name || "").split(' ');
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
email: foundInvite.email,
|
||||
first_name: nameParts[0] || "",
|
||||
last_name: nameParts.slice(1).join(' ') || "",
|
||||
phone: foundInvite.phone || "",
|
||||
department: foundInvite.department || "",
|
||||
hub: foundInvite.hub || "",
|
||||
title: foundInvite.title || ""
|
||||
}));
|
||||
}
|
||||
|
||||
return foundInvite;
|
||||
},
|
||||
enabled: !!inviteCode,
|
||||
});
|
||||
|
||||
// Fetch available hubs for the team
|
||||
const { data: hubs = [] } = useQuery({
|
||||
queryKey: ['team-hubs-onboarding', invite?.team_id],
|
||||
queryFn: async () => {
|
||||
if (!invite?.team_id) return [];
|
||||
const allHubs = await base44.entities.TeamHub.list();
|
||||
return allHubs.filter(h => h.team_id === invite.team_id && h.is_active);
|
||||
},
|
||||
enabled: !!invite?.team_id,
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
// Fetch team to get departments
|
||||
const { data: team } = useQuery({
|
||||
queryKey: ['team-for-departments', invite?.team_id],
|
||||
queryFn: async () => {
|
||||
if (!invite?.team_id) return null;
|
||||
const allTeams = await base44.entities.Team.list();
|
||||
return allTeams.find(t => t.id === invite.team_id);
|
||||
},
|
||||
enabled: !!invite?.team_id,
|
||||
});
|
||||
|
||||
// Get all unique departments from team and hubs
|
||||
const availableDepartments = React.useMemo(() => {
|
||||
const depts = new Set();
|
||||
|
||||
// Add team departments
|
||||
if (team?.departments) {
|
||||
team.departments.forEach(d => depts.add(d));
|
||||
}
|
||||
|
||||
// Add hub departments
|
||||
hubs.forEach(hub => {
|
||||
if (hub.departments) {
|
||||
hub.departments.forEach(dept => {
|
||||
if (dept.department_name) {
|
||||
depts.add(dept.department_name);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(depts);
|
||||
}, [team, hubs]);
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async (data) => {
|
||||
if (!invite) {
|
||||
throw new Error("Invalid invitation. Please contact your team administrator.");
|
||||
}
|
||||
|
||||
// Check if invite was already accepted
|
||||
if (invite.invite_status !== 'pending') {
|
||||
throw new Error("This invitation has already been used. Please contact your team administrator for a new invitation.");
|
||||
}
|
||||
|
||||
// Check for duplicate email in TeamMember
|
||||
const allMembers = await base44.entities.TeamMember.list();
|
||||
const existingMemberByEmail = allMembers.find(m =>
|
||||
m.email?.toLowerCase() === data.email.toLowerCase() && m.team_id === invite.team_id
|
||||
);
|
||||
|
||||
if (existingMemberByEmail) {
|
||||
throw new Error(`A team member with email ${data.email} already exists in this team. Please contact your team administrator.`);
|
||||
}
|
||||
|
||||
// Check for duplicate phone in TeamMember
|
||||
if (data.phone) {
|
||||
const existingMemberByPhone = allMembers.find(m =>
|
||||
m.phone === data.phone && m.team_id === invite.team_id
|
||||
);
|
||||
|
||||
if (existingMemberByPhone) {
|
||||
throw new Error(`A team member with phone number ${data.phone} already exists in this team. Please contact your team administrator.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create team member record
|
||||
const member = await base44.entities.TeamMember.create({
|
||||
team_id: invite.team_id,
|
||||
member_name: `${data.first_name} ${data.last_name}`.trim(),
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
title: data.title,
|
||||
department: data.department,
|
||||
hub: data.hub,
|
||||
role: invite.role || "member",
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
// Update invite status to accepted
|
||||
await base44.entities.TeamMemberInvite.update(invite.id, {
|
||||
invite_status: "accepted",
|
||||
accepted_date: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Update team member counts and refresh
|
||||
const allTeams = await base44.entities.Team.list();
|
||||
const team = allTeams.find(t => t.id === invite.team_id);
|
||||
if (team) {
|
||||
const allMembers = await base44.entities.TeamMember.list();
|
||||
const teamMembers = allMembers.filter(m => m.team_id === team.id);
|
||||
const activeCount = teamMembers.filter(m => m.is_active).length;
|
||||
|
||||
await base44.entities.Team.update(team.id, {
|
||||
total_members: teamMembers.length,
|
||||
active_members: activeCount,
|
||||
total_hubs: team.total_hubs || 0
|
||||
});
|
||||
}
|
||||
|
||||
return { member, invite, team };
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Registration complete - user will see success message in UI
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "❌ Registration Failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate all fields
|
||||
if (!formData.first_name || !formData.last_name || !formData.email || !formData.phone) {
|
||||
toast({
|
||||
title: "Missing Information",
|
||||
description: "Please fill in your name, email, and phone number",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.title || !formData.department) {
|
||||
toast({
|
||||
title: "Missing Information",
|
||||
description: "Please fill in your title and department",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.password || formData.password !== formData.confirmPassword) {
|
||||
toast({
|
||||
title: "Password Mismatch",
|
||||
description: "Passwords do not match",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password.length < 6) {
|
||||
toast({
|
||||
title: "Password Too Short",
|
||||
description: "Password must be at least 6 characters",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit registration
|
||||
registerMutation.mutate(formData);
|
||||
};
|
||||
|
||||
if (!inviteCode || !invite) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center p-4">
|
||||
<Card className="max-w-md w-full border-2 border-red-200">
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<span className="text-4xl">❌</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-[#1C323E] mb-4">Invalid Invitation</h2>
|
||||
<p className="text-slate-600 mb-6">
|
||||
This invitation link is invalid or has expired. Please contact your team administrator for a new invitation.
|
||||
</p>
|
||||
<Button onClick={() => navigate(createPageUrl("Home"))} variant="outline">
|
||||
Go to Home
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if invite was already accepted
|
||||
if (invite.invite_status === 'accepted') {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center p-4">
|
||||
<Card className="max-w-md w-full border-2 border-blue-200">
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<span className="text-4xl">⚠️</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-[#1C323E] mb-4">Invitation Already Used</h2>
|
||||
<p className="text-slate-600 mb-6">
|
||||
This invitation has already been accepted. If you need access, please contact your team administrator.
|
||||
</p>
|
||||
<Button onClick={() => navigate(createPageUrl("Home"))} variant="outline">
|
||||
Go to Home
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center p-4">
|
||||
<div className="max-w-4xl w-full">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-full mb-4">
|
||||
<UserPlus className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-[#1C323E] to-[#0A39DF] bg-clip-text text-transparent mb-2">
|
||||
Join {invite.team_name}
|
||||
</h1>
|
||||
{invite.hub && (
|
||||
<div className="inline-block bg-blue-600 text-white px-6 py-2 rounded-full font-bold mb-3 shadow-lg">
|
||||
📍 {invite.hub}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-slate-600">
|
||||
You've been invited by {invite.invited_by} as a <strong>{invite.role}</strong>
|
||||
{invite.department && <span> in <strong>{invite.department}</strong></span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{registerMutation.isSuccess ? (
|
||||
<Card className="border-2 border-green-200 shadow-xl">
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full mb-6">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-600" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-[#1C323E] mb-4">
|
||||
Welcome to {invite.team_name}! 🎉
|
||||
</h2>
|
||||
<p className="text-slate-600 mb-2">
|
||||
Your registration has been completed successfully!
|
||||
</p>
|
||||
<div className="bg-blue-50 border border-blue-200 p-6 rounded-lg mb-8">
|
||||
<h3 className="font-semibold text-blue-900 mb-3 text-lg">Your Profile Summary:</h3>
|
||||
<div className="space-y-2 text-sm text-slate-700">
|
||||
<p><strong>Name:</strong> {formData.first_name} {formData.last_name}</p>
|
||||
<p><strong>Email:</strong> {formData.email}</p>
|
||||
<p><strong>Title:</strong> {formData.title}</p>
|
||||
<p><strong>Department:</strong> {formData.department}</p>
|
||||
{formData.hub && <p><strong>Hub:</strong> {formData.hub}</p>}
|
||||
<p><strong>Team:</strong> {invite.team_name}</p>
|
||||
<p><strong>Role:</strong> {invite.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border-2 border-blue-600 p-4 rounded-lg mb-6">
|
||||
<p className="text-slate-700 mb-2">
|
||||
<strong>Next Step:</strong> Please sign in with your new credentials to access your dashboard.
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
Use the email <strong>{formData.email}</strong> and the password you just created.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => base44.auth.redirectToLogin()}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white h-12 text-lg font-bold"
|
||||
>
|
||||
Sign In Now →
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="border-2 border-slate-200 shadow-xl">
|
||||
<CardHeader className="bg-gradient-to-r from-slate-50 to-blue-50 border-b">
|
||||
<CardTitle className="text-xl">Complete Your Registration</CardTitle>
|
||||
<p className="text-sm text-slate-500 mt-2">Fill in all the details below to join the team</p>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Basic Information */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-4 pb-2 border-b">
|
||||
<User className="w-5 h-5 text-[#0A39DF]" />
|
||||
<h3 className="font-bold text-[#1C323E]">Basic Information</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="first_name">First Name *</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
||||
placeholder="John"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="last_name">Last Name *</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
|
||||
placeholder="Doe"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">Email *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="john@example.com"
|
||||
className="mt-2"
|
||||
disabled={!!invite}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="phone">Phone Number *</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Work Information */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-4 pb-2 border-b mt-4">
|
||||
<Briefcase className="w-5 h-5 text-[#0A39DF]" />
|
||||
<h3 className="font-bold text-[#1C323E]">Work Information</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="title">Job Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="e.g., Manager, Coordinator"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="department">Department *</Label>
|
||||
<Select value={formData.department} onValueChange={(value) => setFormData({ ...formData, department: value })}>
|
||||
<SelectTrigger className="mt-2">
|
||||
<SelectValue placeholder="Select department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableDepartments.length > 0 ? (
|
||||
availableDepartments.map((dept) => (
|
||||
<SelectItem key={dept} value={dept}>
|
||||
{dept}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="Operations">Operations</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{hubs.length > 0 && (
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="hub">Hub Location</Label>
|
||||
<Select value={formData.hub} onValueChange={(value) => setFormData({ ...formData, hub: value })}>
|
||||
<SelectTrigger className="mt-2">
|
||||
<SelectValue placeholder="Select hub location" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>No Hub</SelectItem>
|
||||
{hubs.map((hub) => (
|
||||
<SelectItem key={hub.id} value={hub.hub_name}>
|
||||
{hub.hub_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.hub && (
|
||||
<p className="text-xs text-blue-600 font-semibold mt-1 bg-blue-50 p-2 rounded">📍 You're joining {formData.hub}!</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account Security */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-4 pb-2 border-b mt-4">
|
||||
<Lock className="w-5 h-5 text-[#0A39DF]" />
|
||||
<h3 className="font-bold text-[#1C323E]">Account Security</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password">Password *</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Minimum 6 characters</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="confirmPassword">Confirm Password *</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={registerMutation.isPending}
|
||||
className="w-full mt-8 bg-blue-600 hover:bg-blue-700 text-white h-12 text-lg font-bold"
|
||||
>
|
||||
{registerMutation.isPending ? 'Creating Account...' : '🚀 Complete Registration'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
245
frontend-web-free/src/pages/OperatorDashboard.jsx
Normal file
245
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
645
frontend-web-free/src/pages/PartnerManagement.jsx
Normal file
645
frontend-web-free/src/pages/PartnerManagement.jsx
Normal file
@@ -0,0 +1,645 @@
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Briefcase, Plus, Search, MapPin, DollarSign, Edit, Building2, TrendingUp, AlertTriangle, CheckCircle2, Users, Target, LayoutGrid, List } from "lucide-react";
|
||||
import PageHeader from "../components/common/PageHeader";
|
||||
|
||||
export default function PartnerManagement() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [viewMode, setViewMode] = useState("grid"); // New state for view mode
|
||||
|
||||
const { data: partners = [], isLoading: partnersLoading } = useQuery({
|
||||
queryKey: ['partners'],
|
||||
queryFn: () => base44.entities.Partner.list('-created_date'),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: businesses = [], isLoading: businessesLoading } = useQuery({
|
||||
queryKey: ['businesses'],
|
||||
queryFn: () => base44.entities.Business.list('-created_date'),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: sectors = [] } = useQuery({
|
||||
queryKey: ['sectors'],
|
||||
queryFn: () => base44.entities.Sector.list('-created_date'),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: enterprises = [] } = useQuery({
|
||||
queryKey: ['enterprises'],
|
||||
queryFn: () => base44.entities.Enterprise.list('-created_date'),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
// Consolidate businesses by company name
|
||||
const consolidatedBusinesses = useMemo(() => {
|
||||
const grouped = {};
|
||||
|
||||
businesses.forEach(business => {
|
||||
let companyName = business.business_name;
|
||||
|
||||
// Extract company name (remove hub suffix if present)
|
||||
const dashIndex = companyName.indexOf(' - ');
|
||||
if (dashIndex > 0) {
|
||||
companyName = companyName.substring(0, dashIndex).trim();
|
||||
}
|
||||
|
||||
if (!grouped[companyName]) {
|
||||
grouped[companyName] = {
|
||||
company_name: companyName,
|
||||
partner_type: "Corporate",
|
||||
hubs: [],
|
||||
primary_contact: business.contact_name,
|
||||
primary_email: business.email,
|
||||
primary_phone: business.phone,
|
||||
sector: business.sector || business.area || '',
|
||||
total_hubs: 0,
|
||||
company_logo: business.company_logo || null,
|
||||
};
|
||||
}
|
||||
|
||||
grouped[companyName].hubs.push({
|
||||
id: business.id,
|
||||
hub_name: business.business_name,
|
||||
contact_name: business.contact_name,
|
||||
email: business.email,
|
||||
phone: business.phone,
|
||||
address: business.address,
|
||||
city: business.city,
|
||||
area: business.area,
|
||||
rate_group: business.rate_group,
|
||||
company_logo: business.company_logo,
|
||||
});
|
||||
grouped[companyName].total_hubs++;
|
||||
|
||||
// Use the first hub's logo if available
|
||||
if (business.company_logo && !grouped[companyName].company_logo) {
|
||||
grouped[companyName].company_logo = business.company_logo;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(grouped);
|
||||
}, [businesses]);
|
||||
|
||||
// Operator coverage data
|
||||
const operatorMetrics = {
|
||||
totalCoverage: 94,
|
||||
activeIncidents: 2,
|
||||
clientSatisfaction: 4.8,
|
||||
forecastAccuracy: 91
|
||||
};
|
||||
|
||||
const hubCoverageData = [
|
||||
{ hub: 'San Jose', enterprise: 'Compass', sector: 'Bon Appétit', coverage: 97, incidents: 0, satisfaction: 4.9, partners: 12 },
|
||||
{ hub: 'San Francisco', enterprise: 'Compass', sector: 'Eurest', coverage: 92, incidents: 1, satisfaction: 4.7, partners: 8 },
|
||||
{ hub: 'Oakland', enterprise: 'Compass', sector: 'Bon Appétit', coverage: 89, incidents: 2, satisfaction: 4.5, partners: 6 },
|
||||
{ hub: 'Sacramento', enterprise: 'Compass', sector: 'Chartwells', coverage: 95, incidents: 1, satisfaction: 4.8, partners: 10 },
|
||||
];
|
||||
|
||||
const filteredPartners = partners.filter(p =>
|
||||
!searchTerm ||
|
||||
p.partner_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
p.partner_number?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const filteredBusinesses = consolidatedBusinesses.filter(b =>
|
||||
!searchTerm ||
|
||||
b.company_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
b.primary_contact?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const totalPartnerCount = filteredPartners.length + filteredBusinesses.length;
|
||||
const totalHubCount = filteredBusinesses.reduce((sum, b) => sum + b.total_hubs, 0) +
|
||||
filteredPartners.reduce((sum, p) => sum + (p.sites?.length || 0), 0);
|
||||
|
||||
const isLoading = partnersLoading || businessesLoading;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<PageHeader
|
||||
title="Partners & Operators"
|
||||
subtitle={`${totalPartnerCount} partners • ${totalHubCount} total hubs • ${sectors.length} sectors • ${enterprises.length} enterprises`}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Link to={createPageUrl("AddPartner")}>
|
||||
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Partner
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={createPageUrl("Business")}>
|
||||
<Button variant="outline" className="border-slate-300">
|
||||
<Building2 className="w-4 h-4 mr-2" />
|
||||
Business Directory
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Operator Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<Card className="border-slate-200 shadow-lg">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Target className="w-8 h-8 text-[#0A39DF]" />
|
||||
<Badge className="bg-emerald-100 text-emerald-700">+5%</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">Coverage Rate</p>
|
||||
<p className="text-3xl font-bold text-[#1C323E]">{operatorMetrics.totalCoverage}%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 shadow-lg">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<AlertTriangle className="w-8 h-8 text-amber-600" />
|
||||
<Badge className="bg-green-100 text-green-700">Low</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">Active Incidents</p>
|
||||
<p className="text-3xl font-bold text-[#1C323E]">{operatorMetrics.activeIncidents}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 shadow-lg">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<CheckCircle2 className="w-8 h-8 text-emerald-600" />
|
||||
<Badge className="bg-blue-100 text-blue-700">{operatorMetrics.clientSatisfaction}/5.0</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">Client Satisfaction</p>
|
||||
<p className="text-3xl font-bold text-[#1C323E]">{operatorMetrics.clientSatisfaction}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 shadow-lg">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<TrendingUp className="w-8 h-8 text-purple-600" />
|
||||
<Badge className="bg-purple-100 text-purple-700">{operatorMetrics.forecastAccuracy}%</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">Forecast Accuracy</p>
|
||||
<p className="text-3xl font-bold text-[#1C323E]">{operatorMetrics.forecastAccuracy}%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<Card className="mb-6 border-slate-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search partners or businesses..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Live Operator Coverage Map */}
|
||||
<Card className="border-slate-200 shadow-lg mb-8">
|
||||
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5 text-[#0A39DF]" />
|
||||
Live Operator Coverage Map
|
||||
</CardTitle>
|
||||
<p className="text-sm text-slate-500 mt-1">Real-time coverage across all hubs and sectors</p>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{hubCoverageData.map((hub, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-6 rounded-xl border-2 border-slate-200 hover:border-[#0A39DF] hover:shadow-lg transition-all bg-white"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
hub.coverage >= 95 ? 'bg-emerald-100' :
|
||||
hub.coverage >= 90 ? 'bg-blue-100' :
|
||||
'bg-amber-100'
|
||||
}`}>
|
||||
<MapPin className={`w-6 h-6 ${
|
||||
hub.coverage >= 95 ? 'text-emerald-600' :
|
||||
hub.coverage >= 90 ? 'text-blue-600' :
|
||||
'text-amber-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-[#1C323E]">{hub.hub}</h4>
|
||||
<p className="text-xs text-slate-500">{hub.enterprise} • {hub.sector}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={`${
|
||||
hub.coverage >= 95 ? 'bg-emerald-100 text-emerald-700' :
|
||||
hub.coverage >= 90 ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-amber-100 text-amber-700'
|
||||
} text-lg px-3 py-1 font-bold`}>
|
||||
{hub.coverage}%
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 text-sm mb-4">
|
||||
<div className="p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-xs text-slate-500 mb-1">Partners</p>
|
||||
<p className="font-bold text-[#0A39DF]">{hub.partners}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-xs text-slate-500 mb-1">Incidents</p>
|
||||
<p className={`font-bold ${hub.incidents > 0 ? 'text-red-600' : 'text-emerald-600'}`}>
|
||||
{hub.incidents}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-xs text-slate-500 mb-1">Rating</p>
|
||||
<p className="font-bold text-amber-600">{hub.satisfaction}/5.0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-slate-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${
|
||||
hub.coverage >= 95 ? 'bg-gradient-to-r from-emerald-500 to-emerald-600' :
|
||||
hub.coverage >= 90 ? 'bg-gradient-to-r from-blue-500 to-blue-600' :
|
||||
'bg-gradient-to-r from-amber-500 to-amber-600'
|
||||
}`}
|
||||
style={{ width: `${hub.coverage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Organizational Hierarchy */}
|
||||
<div className="mt-8 pt-6 border-t border-slate-200">
|
||||
<h3 className="text-base font-bold text-[#1C323E] mb-4 flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-[#0A39DF]" />
|
||||
Enterprise & Sector Overview
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="p-5 bg-gradient-to-br from-indigo-50 to-white rounded-xl border-2 border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center">
|
||||
<Building2 className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Enterprises</p>
|
||||
<p className="text-2xl font-bold text-[#1C323E]">{enterprises.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link to={createPageUrl("EnterpriseManagement")}>
|
||||
<Button variant="outline" size="sm" className="w-full text-xs border-slate-300">
|
||||
Manage Enterprises
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-gradient-to-br from-purple-50 to-white rounded-xl border-2 border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<MapPin className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Sectors</p>
|
||||
<p className="text-2xl font-bold text-[#1C323E]">{sectors.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link to={createPageUrl("SectorManagement")}>
|
||||
<Button variant="outline" size="sm" className="w-full text-xs border-slate-300">
|
||||
Manage Sectors
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-gradient-to-br from-green-50 to-white rounded-xl border-2 border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<Briefcase className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Partners</p>
|
||||
<p className="text-2xl font-bold text-[#1C323E]">{totalPartnerCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs border-slate-300"
|
||||
onClick={() => {
|
||||
const partnersSection = document.getElementById('partners-section');
|
||||
partnersSection?.scrollIntoView({ behavior: 'smooth' });
|
||||
}}
|
||||
>
|
||||
View Partners
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Partners Grid/List */}
|
||||
<div id="partners-section">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-[#1C323E] flex items-center gap-2">
|
||||
<Briefcase className="w-6 h-6 text-[#0A39DF]" />
|
||||
Partner Directory
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={() => setViewMode("grid")}
|
||||
className={viewMode === "grid" ? "bg-[#0A39DF] hover:bg-[#0A39DF]/90" : "border-slate-300 text-slate-500 hover:text-[#0A39DF]"}
|
||||
>
|
||||
<LayoutGrid className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={() => setViewMode("list")}
|
||||
className={viewMode === "list" ? "bg-[#0A39DF] hover:bg-[#0A39DF]/90" : "border-slate-300 text-slate-500 hover:text-[#0A39DF]"}
|
||||
>
|
||||
<List className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="h-64 bg-slate-100 animate-pulse rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : (filteredPartners.length > 0 || filteredBusinesses.length > 0) ? (
|
||||
<>
|
||||
{/* Grid View */}
|
||||
{viewMode === "grid" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Display Businesses from Business Directory */}
|
||||
{filteredBusinesses.map((business, idx) => (
|
||||
<Card key={`business-${idx}`} className="border-2 border-slate-200 hover:border-[#0A39DF] hover:shadow-xl transition-all">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="w-14 h-14 rounded-xl flex items-center justify-center overflow-hidden bg-white border-2 border-slate-200 flex-shrink-0">
|
||||
{business.company_logo ? (
|
||||
<img
|
||||
src={business.company_logo}
|
||||
alt={business.company_name}
|
||||
className="w-full h-full object-contain p-2"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-green-500 to-green-700 rounded-lg flex items-center justify-center text-white font-bold text-xl">
|
||||
{business.company_name?.charAt(0) || 'B'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-xl text-[#1C323E] mb-1 truncate">
|
||||
{business.company_name}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">PN-{String(idx + 1000).padStart(4, '0')}</p>
|
||||
</div>
|
||||
<Link to={createPageUrl("Business")}>
|
||||
<Button variant="ghost" size="icon" className="text-slate-400 hover:text-[#0A39DF] hover:bg-blue-50 flex-shrink-0">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-slate-600">Type</span>
|
||||
<span className="font-semibold text-sm text-[#1C323E]">{business.partner_type}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-slate-600">Sector</span>
|
||||
<span className="font-semibold text-sm text-[#1C323E]">{business.sector || '—'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-slate-600 flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
Sites
|
||||
</span>
|
||||
<span className="font-semibold text-[#1C323E]">{business.total_hubs}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-slate-600 flex items-center gap-1">
|
||||
<DollarSign className="w-3 h-3" />
|
||||
Terms
|
||||
</span>
|
||||
<span className="font-semibold text-[#1C323E]">Net 30</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 mt-3 border-t border-slate-200">
|
||||
<Badge className="bg-green-100 text-green-700">
|
||||
Active
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Display Traditional Partners */}
|
||||
{filteredPartners.map((partner) => (
|
||||
<Card key={partner.id} className="border-2 border-slate-200 hover:border-[#0A39DF] hover:shadow-xl transition-all">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-green-500 to-green-700 rounded-xl flex items-center justify-center text-white flex-shrink-0">
|
||||
<Briefcase className="w-7 h-7" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-xl text-[#1C323E] mb-1 truncate">
|
||||
{partner.partner_name}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">{partner.partner_number}</p>
|
||||
</div>
|
||||
<Link to={createPageUrl(`EditPartner?id=${partner.id}`)}>
|
||||
<Button variant="ghost" size="icon" className="text-slate-400 hover:text-[#0A39DF] hover:bg-blue-50 flex-shrink-0">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-slate-600">Type</span>
|
||||
<span className="font-semibold text-sm text-[#1C323E]">{partner.partner_type}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-slate-600">Sector</span>
|
||||
<span className="font-semibold text-sm text-[#1C323E]">{partner.sector_name || '—'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-slate-600 flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
Sites
|
||||
</span>
|
||||
<span className="font-semibold text-[#1C323E]">{partner.sites?.length || 0}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-slate-600 flex items-center gap-1">
|
||||
<DollarSign className="w-3 h-3" />
|
||||
Terms
|
||||
</span>
|
||||
<span className="font-semibold text-[#1C323E]">{partner.payment_terms || 'Net 30'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 mt-3 border-t border-slate-200">
|
||||
<Badge className={partner.is_active ? "bg-green-100 text-green-700" : "bg-slate-100 text-slate-700"}>
|
||||
{partner.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List View */}
|
||||
{viewMode === "list" && (
|
||||
<Card className="border-slate-200 shadow-lg">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50 border-b-2 border-slate-200">
|
||||
<tr>
|
||||
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Partner Name</th>
|
||||
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Type</th>
|
||||
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Sector</th>
|
||||
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Hubs/Sites</th>
|
||||
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Contact</th>
|
||||
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Status</th>
|
||||
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Display Businesses from Business Directory */}
|
||||
{filteredBusinesses.map((business, idx) => (
|
||||
<tr key={`business-${idx}`} className="border-b border-slate-100 hover:bg-slate-50 transition-colors">
|
||||
<td className="py-4 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center overflow-hidden bg-white border-2 border-slate-200 flex-shrink-0">
|
||||
{business.company_logo ? (
|
||||
<img
|
||||
src={business.company_logo}
|
||||
alt={business.company_name}
|
||||
className="w-full h-full object-contain p-1"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-blue-500 to-blue-700 rounded-lg flex items-center justify-center text-white font-bold text-sm">
|
||||
{business.company_name?.charAt(0) || 'B'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-[#1C323E]">{business.company_name}</p>
|
||||
<Badge variant="outline" className="text-xs mt-1">From Business Directory</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-4 text-sm text-slate-700">{business.partner_type}</td>
|
||||
<td className="py-4 px-4 text-sm text-slate-700">{business.sector || '—'}</td>
|
||||
<td className="py-4 px-4 text-center">
|
||||
<Badge className="bg-blue-100 text-blue-700 font-semibold">
|
||||
{business.total_hubs}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-4 px-4 text-sm text-slate-700">{business.primary_contact || '—'}</td>
|
||||
<td className="py-4 px-4 text-center">
|
||||
<Badge className="bg-green-100 text-green-700">Active</Badge>
|
||||
</td>
|
||||
<td className="py-4 px-4 text-center">
|
||||
<Link to={createPageUrl("Business")}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* Display Traditional Partners */}
|
||||
{filteredPartners.map((partner) => (
|
||||
<tr key={partner.id} className="border-b border-slate-100 hover:bg-slate-50 transition-colors">
|
||||
<td className="py-4 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-green-500 to-green-700 rounded-lg flex items-center justify-center text-white flex-shrink-0">
|
||||
<Briefcase className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-[#1C323E]">{partner.partner_name}</p>
|
||||
<p className="text-xs text-slate-500">{partner.partner_number}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-4 text-sm text-slate-700">{partner.partner_type}</td>
|
||||
<td className="py-4 px-4 text-sm text-slate-700">{partner.sector_name || '—'}</td>
|
||||
<td className="py-4 px-4 text-center">
|
||||
<Badge className="bg-purple-100 text-purple-700 font-semibold">
|
||||
{partner.sites?.length || 0}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-4 px-4 text-sm text-slate-700">{partner.primary_contact_name || '—'}</td>
|
||||
<td className="py-4 px-4 text-center">
|
||||
<Badge className={partner.is_active ? "bg-green-100 text-green-700" : "bg-slate-100 text-slate-700"}>
|
||||
{partner.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-4 px-4 text-center">
|
||||
<Link to={createPageUrl(`EditPartner?id=${partner.id}`)}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Briefcase className="w-16 h-16 mx-auto text-slate-300 mb-4" />
|
||||
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Partners Found</h3>
|
||||
<p className="text-slate-500 mb-6">Add your first partner client</p>
|
||||
<Link to={createPageUrl("AddPartner")}>
|
||||
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add First Partner
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend-web-free/src/pages/Payroll.jsx
Normal file
19
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
977
frontend-web-free/src/pages/Permissions.jsx
Normal file
977
frontend-web-free/src/pages/Permissions.jsx
Normal file
@@ -0,0 +1,977 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Shield, Search, Save, Info, ChevronDown, ChevronRight, Users, Calendar,
|
||||
Package, DollarSign, FileText, Settings as SettingsIcon, BarChart3,
|
||||
MessageSquare, Briefcase, Building2, Layers, Lock, Unlock, Eye,
|
||||
CheckCircle2, XCircle, AlertTriangle, Sparkles, UserCog, Plus, Trash2, Copy
|
||||
} from "lucide-react";
|
||||
import PageHeader from "../components/common/PageHeader";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
// Layer hierarchy configuration
|
||||
const LAYER_HIERARCHY = [
|
||||
{
|
||||
id: "admin",
|
||||
name: "KROW Admin",
|
||||
icon: Shield,
|
||||
color: "from-red-500 to-red-700",
|
||||
bgColor: "bg-red-50",
|
||||
borderColor: "border-red-200",
|
||||
textColor: "text-red-700",
|
||||
description: "Full platform control and oversight",
|
||||
level: 1
|
||||
},
|
||||
{
|
||||
id: "procurement",
|
||||
name: "Procurement",
|
||||
icon: Briefcase,
|
||||
color: "from-[#0A39DF] to-[#1C323E]",
|
||||
bgColor: "bg-blue-50",
|
||||
borderColor: "border-blue-200",
|
||||
textColor: "text-blue-700",
|
||||
description: "Vendor management and rate control",
|
||||
level: 2
|
||||
},
|
||||
{
|
||||
id: "operator",
|
||||
name: "Operator",
|
||||
icon: Building2,
|
||||
color: "from-emerald-500 to-emerald-700",
|
||||
bgColor: "bg-emerald-50",
|
||||
borderColor: "border-emerald-200",
|
||||
textColor: "text-emerald-700",
|
||||
description: "Enterprise-wide operations",
|
||||
level: 3
|
||||
},
|
||||
{
|
||||
id: "sector",
|
||||
name: "Sector",
|
||||
icon: Layers,
|
||||
color: "from-purple-500 to-purple-700",
|
||||
bgColor: "bg-purple-50",
|
||||
borderColor: "border-purple-200",
|
||||
textColor: "text-purple-700",
|
||||
description: "Location-specific management",
|
||||
level: 4
|
||||
},
|
||||
{
|
||||
id: "client",
|
||||
name: "Client",
|
||||
icon: Users,
|
||||
color: "from-green-500 to-green-700",
|
||||
bgColor: "bg-green-50",
|
||||
borderColor: "border-green-200",
|
||||
textColor: "text-green-700",
|
||||
description: "Service ordering and review",
|
||||
level: 5
|
||||
},
|
||||
{
|
||||
id: "vendor",
|
||||
name: "Vendor",
|
||||
icon: Package,
|
||||
color: "from-amber-500 to-amber-700",
|
||||
bgColor: "bg-amber-50",
|
||||
borderColor: "border-amber-200",
|
||||
textColor: "text-amber-700",
|
||||
description: "Workforce supply and management",
|
||||
level: 6
|
||||
},
|
||||
{
|
||||
id: "workforce",
|
||||
name: "Workforce",
|
||||
icon: Users,
|
||||
color: "from-slate-500 to-slate-700",
|
||||
bgColor: "bg-slate-50",
|
||||
borderColor: "border-slate-200",
|
||||
textColor: "text-slate-700",
|
||||
description: "Shift work and earnings",
|
||||
level: 7
|
||||
}
|
||||
];
|
||||
|
||||
// Permission modules for each layer
|
||||
const PERMISSION_MODULES = {
|
||||
admin: [
|
||||
{
|
||||
id: "platform",
|
||||
name: "Platform Administration",
|
||||
icon: Shield,
|
||||
permissions: [
|
||||
{ id: "platform.users", name: "Manage All Users", description: "Create, edit, delete users across all layers", critical: true },
|
||||
{ id: "platform.settings", name: "System Settings", description: "Configure platform-wide settings", critical: true },
|
||||
{ id: "platform.roles", name: "Role Management", description: "Define and modify role permissions", critical: true },
|
||||
{ id: "platform.audit", name: "Audit Logs", description: "Access complete system audit trail" },
|
||||
{ id: "platform.integrations", name: "Integrations", description: "Manage external system connections" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "hierarchy",
|
||||
name: "Hierarchy Control",
|
||||
icon: Layers,
|
||||
permissions: [
|
||||
{ id: "hierarchy.enterprises", name: "Enterprise Management", description: "Create and manage enterprises" },
|
||||
{ id: "hierarchy.sectors", name: "Sector Management", description: "Create and manage sectors" },
|
||||
{ id: "hierarchy.partners", name: "Partner Management", description: "Manage partner relationships" },
|
||||
{ id: "hierarchy.policies", name: "Global Policies", description: "Set enterprise-wide policies", critical: true },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "financial",
|
||||
name: "Financial Control",
|
||||
icon: DollarSign,
|
||||
permissions: [
|
||||
{ id: "financial.all", name: "All Financials", description: "Access all financial data", critical: true },
|
||||
{ id: "financial.payments", name: "Process Payments", description: "Approve and process all payments" },
|
||||
{ id: "financial.payroll", name: "Payroll Management", description: "Process workforce payroll" },
|
||||
{ id: "financial.reports", name: "Financial Reports", description: "Generate P&L and analytics" },
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
procurement: [
|
||||
{
|
||||
id: "vendors",
|
||||
name: "Vendor Management",
|
||||
icon: Package,
|
||||
permissions: [
|
||||
{ id: "vendors.view", name: "View Vendors", description: "Access vendor directory" },
|
||||
{ id: "vendors.onboard", name: "Onboard Vendors", description: "Add new vendors to platform" },
|
||||
{ id: "vendors.edit", name: "Edit Vendors", description: "Modify vendor information" },
|
||||
{ id: "vendors.compliance", name: "Compliance Review", description: "Review COI, W9, certifications" },
|
||||
{ id: "vendors.approve", name: "Approve/Suspend", description: "Change vendor approval status", critical: true },
|
||||
{ id: "vendors.performance", name: "Performance Data", description: "Access scorecards and KPIs" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "rates",
|
||||
name: "Rate Management",
|
||||
icon: DollarSign,
|
||||
permissions: [
|
||||
{ id: "rates.view", name: "View Rates", description: "See all vendor pricing" },
|
||||
{ id: "rates.create", name: "Create Rate Cards", description: "Set up new rate cards" },
|
||||
{ id: "rates.edit", name: "Edit Rates", description: "Modify existing rates" },
|
||||
{ id: "rates.approve", name: "Approve Rates", description: "Approve vendor rate submissions", critical: true },
|
||||
{ id: "rates.markup", name: "Markup Rules", description: "Define markup percentages" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "orders",
|
||||
name: "Order Oversight",
|
||||
icon: Calendar,
|
||||
permissions: [
|
||||
{ id: "orders.viewAll", name: "View All Orders", description: "Access orders across sectors" },
|
||||
{ id: "orders.assign", name: "Assign Vendors", description: "Match vendors with orders" },
|
||||
{ id: "orders.monitor", name: "Monitor Fulfillment", description: "Track order completion" },
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
operator: [
|
||||
{
|
||||
id: "events",
|
||||
name: "Event Management",
|
||||
icon: Calendar,
|
||||
permissions: [
|
||||
{ id: "events.view", name: "View Events", description: "See events in my enterprise" },
|
||||
{ id: "events.create", name: "Create Events", description: "Create new event orders" },
|
||||
{ id: "events.edit", name: "Edit Events", description: "Modify event details" },
|
||||
{ id: "events.cancel", name: "Cancel Events", description: "Cancel event orders" },
|
||||
{ id: "events.approve", name: "Approve Events", description: "Approve sector event requests" },
|
||||
{ id: "events.financials", name: "Event Financials", description: "View event costs and billing" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "sectors",
|
||||
name: "Sector Oversight",
|
||||
icon: Building2,
|
||||
permissions: [
|
||||
{ id: "sectors.view", name: "View Sectors", description: "See sectors under my enterprise" },
|
||||
{ id: "sectors.settings", name: "Sector Settings", description: "Configure sector policies" },
|
||||
{ id: "sectors.vendors", name: "Assign Vendors", description: "Approve vendors for sectors" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "workforce",
|
||||
name: "Workforce Management",
|
||||
icon: Users,
|
||||
permissions: [
|
||||
{ id: "workforce.view", name: "View Workforce", description: "See staff across enterprise" },
|
||||
{ id: "workforce.assign", name: "Assign Staff", description: "Schedule staff for events" },
|
||||
{ id: "workforce.timesheets", name: "Approve Timesheets", description: "Review and approve hours" },
|
||||
{ id: "workforce.performance", name: "Staff Performance", description: "Access ratings and reviews" },
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
sector: [
|
||||
{
|
||||
id: "events",
|
||||
name: "Location Events",
|
||||
icon: Calendar,
|
||||
permissions: [
|
||||
{ id: "events.viewMy", name: "View My Events", description: "See events at my location" },
|
||||
{ id: "events.request", name: "Request Events", description: "Submit new event requests" },
|
||||
{ id: "events.editMy", name: "Edit My Events", description: "Modify my event details" },
|
||||
{ id: "events.costs", name: "View Costs", description: "See event billing information" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "staff",
|
||||
name: "Staff Management",
|
||||
icon: Users,
|
||||
permissions: [
|
||||
{ id: "staff.view", name: "View Staff", description: "See staff at my sector" },
|
||||
{ id: "staff.schedule", name: "Schedule Staff", description: "Assign staff to shifts" },
|
||||
{ id: "staff.timesheets", name: "Approve Timesheets", description: "Review hours worked" },
|
||||
{ id: "staff.rate", name: "Rate Performance", description: "Provide staff feedback" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "vendors",
|
||||
name: "Vendor Access",
|
||||
icon: Package,
|
||||
permissions: [
|
||||
{ id: "vendors.viewApproved", name: "View Vendors", description: "See approved vendors" },
|
||||
{ id: "vendors.rates", name: "View Rates", description: "Access rate cards" },
|
||||
{ id: "vendors.request", name: "Request Services", description: "Submit staffing requests" },
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
client: [
|
||||
{
|
||||
id: "orders",
|
||||
name: "My Orders",
|
||||
icon: Calendar,
|
||||
permissions: [
|
||||
{ id: "orders.view", name: "View Orders", description: "See my event orders" },
|
||||
{ id: "orders.create", name: "Create Orders", description: "Request staffing for events" },
|
||||
{ id: "orders.edit", name: "Edit Orders", description: "Modify orders before confirmation" },
|
||||
{ id: "orders.cancel", name: "Cancel Orders", description: "Cancel pending orders" },
|
||||
{ id: "orders.status", name: "Track Status", description: "Monitor order fulfillment" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "vendors",
|
||||
name: "Vendor Selection",
|
||||
icon: Package,
|
||||
permissions: [
|
||||
{ id: "vendors.browse", name: "Browse Vendors", description: "View available vendors" },
|
||||
{ id: "vendors.rates", name: "View Rates", description: "See service pricing" },
|
||||
{ id: "vendors.prefer", name: "Preferred Vendors", description: "Request specific vendors" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
name: "Billing",
|
||||
icon: DollarSign,
|
||||
permissions: [
|
||||
{ id: "billing.invoices", name: "View Invoices", description: "Access my invoices" },
|
||||
{ id: "billing.download", name: "Download Invoices", description: "Export invoice PDFs" },
|
||||
{ id: "billing.analytics", name: "Spend Analytics", description: "View spending trends" },
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
vendor: [
|
||||
{
|
||||
id: "orders",
|
||||
name: "Order Fulfillment",
|
||||
icon: Calendar,
|
||||
permissions: [
|
||||
{ id: "orders.viewAssigned", name: "View Orders", description: "See assigned orders" },
|
||||
{ id: "orders.respond", name: "Accept/Decline", description: "Respond to order requests" },
|
||||
{ id: "orders.update", name: "Update Status", description: "Mark order progress" },
|
||||
{ id: "orders.details", name: "Order Details", description: "Access requirements" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "workforce",
|
||||
name: "My Workforce",
|
||||
icon: Users,
|
||||
permissions: [
|
||||
{ id: "workforce.view", name: "View Staff", description: "See my workforce" },
|
||||
{ id: "workforce.add", name: "Add Staff", description: "Onboard new workers" },
|
||||
{ id: "workforce.edit", name: "Edit Staff", description: "Update staff info" },
|
||||
{ id: "workforce.assign", name: "Assign Staff", description: "Schedule for orders" },
|
||||
{ id: "workforce.compliance", name: "Manage Compliance", description: "Track certifications" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "rates",
|
||||
name: "My Rates",
|
||||
icon: DollarSign,
|
||||
permissions: [
|
||||
{ id: "rates.viewMy", name: "View Rates", description: "See my approved rates" },
|
||||
{ id: "rates.propose", name: "Propose Rates", description: "Submit rate proposals" },
|
||||
{ id: "rates.history", name: "Rate History", description: "Track rate changes" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "invoices",
|
||||
name: "Invoicing",
|
||||
icon: FileText,
|
||||
permissions: [
|
||||
{ id: "invoices.view", name: "View Invoices", description: "Access my invoices" },
|
||||
{ id: "invoices.create", name: "Create Invoices", description: "Generate invoices" },
|
||||
{ id: "invoices.track", name: "Track Payments", description: "Monitor payment status" },
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
workforce: [
|
||||
{
|
||||
id: "shifts",
|
||||
name: "My Shifts",
|
||||
icon: Calendar,
|
||||
permissions: [
|
||||
{ id: "shifts.view", name: "View Schedule", description: "See upcoming shifts" },
|
||||
{ id: "shifts.clock", name: "Clock In/Out", description: "Record shift times" },
|
||||
{ id: "shifts.timeoff", name: "Request Time Off", description: "Submit time off requests" },
|
||||
{ id: "shifts.history", name: "Shift History", description: "See past shifts" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
name: "My Profile",
|
||||
icon: Users,
|
||||
permissions: [
|
||||
{ id: "profile.view", name: "View Profile", description: "See my worker profile" },
|
||||
{ id: "profile.edit", name: "Edit Contact", description: "Update phone/email" },
|
||||
{ id: "profile.availability", name: "Update Availability", description: "Set available times" },
|
||||
{ id: "profile.certs", name: "Upload Certs", description: "Add certifications" },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "earnings",
|
||||
name: "Earnings",
|
||||
icon: DollarSign,
|
||||
permissions: [
|
||||
{ id: "earnings.view", name: "View Earnings", description: "See pay and hours" },
|
||||
{ id: "earnings.timesheets", name: "View Timesheets", description: "Access timesheet records" },
|
||||
{ id: "earnings.history", name: "Payment History", description: "See past payments" },
|
||||
{ id: "earnings.download", name: "Download Stubs", description: "Export pay stubs" },
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Default position templates
|
||||
const DEFAULT_POSITION_TEMPLATES = [
|
||||
{ id: "manager", position: "Manager", layer: "operator", description: "Full operational access", permissions: ["events.view", "events.create", "events.edit", "events.approve", "sectors.view", "workforce.view", "workforce.assign"] },
|
||||
{ id: "supervisor", position: "Supervisor", layer: "sector", description: "Location-level management", permissions: ["events.viewMy", "events.request", "staff.view", "staff.schedule", "staff.timesheets"] },
|
||||
{ id: "coordinator", position: "Coordinator", layer: "client", description: "Order coordination", permissions: ["orders.view", "orders.create", "orders.status", "vendors.browse"] },
|
||||
{ id: "team_lead", position: "Team Lead", layer: "vendor", description: "Workforce team management", permissions: ["orders.viewAssigned", "workforce.view", "workforce.assign", "rates.viewMy"] },
|
||||
{ id: "staff", position: "Staff Member", layer: "workforce", description: "Basic shift access", permissions: ["shifts.view", "shifts.clock", "profile.view", "earnings.view"] },
|
||||
];
|
||||
|
||||
export default function Permissions() {
|
||||
const [activeTab, setActiveTab] = useState("layers");
|
||||
const [selectedLayer, setSelectedLayer] = useState("operator");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [expandedModules, setExpandedModules] = useState({});
|
||||
const [permissions, setPermissions] = useState({});
|
||||
const [positionTemplates, setPositionTemplates] = useState(DEFAULT_POSITION_TEMPLATES);
|
||||
const [showAddPosition, setShowAddPosition] = useState(false);
|
||||
const [newPosition, setNewPosition] = useState({ position: "", description: "" });
|
||||
const [editingPosition, setEditingPosition] = useState(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-permissions'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const userRole = user?.user_role || user?.role || "admin";
|
||||
|
||||
// Non-admin users can only see their own layer
|
||||
const effectiveLayer = userRole === "admin" ? selectedLayer : userRole;
|
||||
const selectedLayerConfig = LAYER_HIERARCHY.find(l => l.id === effectiveLayer);
|
||||
const modules = PERMISSION_MODULES[effectiveLayer] || [];
|
||||
|
||||
// Filter layer hierarchy for non-admin users
|
||||
const visibleLayers = userRole === "admin" ? LAYER_HIERARCHY : LAYER_HIERARCHY.filter(l => l.id === userRole);
|
||||
|
||||
// Initialize permissions with all enabled for demo
|
||||
React.useEffect(() => {
|
||||
const newPermissions = {};
|
||||
modules.forEach(module => {
|
||||
module.permissions.forEach(perm => {
|
||||
newPermissions[perm.id] = true;
|
||||
});
|
||||
});
|
||||
setPermissions(newPermissions);
|
||||
// Expand all modules by default
|
||||
const expanded = {};
|
||||
modules.forEach(m => expanded[m.id] = true);
|
||||
setExpandedModules(expanded);
|
||||
}, [selectedLayer]);
|
||||
|
||||
const toggleModule = (moduleId) => {
|
||||
setExpandedModules(prev => ({
|
||||
...prev,
|
||||
[moduleId]: !prev[moduleId]
|
||||
}));
|
||||
};
|
||||
|
||||
const togglePermission = (permId) => {
|
||||
setPermissions(prev => ({
|
||||
...prev,
|
||||
[permId]: !prev[permId]
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleAllInModule = (module, value) => {
|
||||
const newPerms = { ...permissions };
|
||||
module.permissions.forEach(p => {
|
||||
newPerms[p.id] = value;
|
||||
});
|
||||
setPermissions(newPerms);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
toast({
|
||||
title: "Permissions Saved",
|
||||
description: `${selectedLayerConfig.name} layer permissions updated successfully.`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddPosition = () => {
|
||||
if (!newPosition.position) return;
|
||||
const id = newPosition.position.toLowerCase().replace(/\s+/g, '_');
|
||||
setPositionTemplates([...positionTemplates, { ...newPosition, id, layer: effectiveLayer, permissions: [] }]);
|
||||
setNewPosition({ position: "", description: "" });
|
||||
setShowAddPosition(false);
|
||||
toast({ title: "Position Added", description: `${newPosition.position} template created.` });
|
||||
};
|
||||
|
||||
const handleDeletePosition = (id) => {
|
||||
setPositionTemplates(positionTemplates.filter(p => p.id !== id));
|
||||
toast({ title: "Position Deleted", description: "Position template removed." });
|
||||
};
|
||||
|
||||
const handleEditPositionPermissions = (position) => {
|
||||
setEditingPosition(position);
|
||||
setSelectedLayer(position.layer);
|
||||
// Load position's permissions
|
||||
const newPerms = {};
|
||||
position.permissions.forEach(p => newPerms[p] = true);
|
||||
setPermissions(newPerms);
|
||||
};
|
||||
|
||||
const handleSavePositionPermissions = () => {
|
||||
if (!editingPosition) return;
|
||||
const enabledPerms = Object.entries(permissions).filter(([_, v]) => v).map(([k]) => k);
|
||||
setPositionTemplates(positionTemplates.map(p =>
|
||||
p.id === editingPosition.id ? { ...p, permissions: enabledPerms } : p
|
||||
));
|
||||
setEditingPosition(null);
|
||||
toast({ title: "Position Updated", description: `${editingPosition.position} permissions saved.` });
|
||||
};
|
||||
|
||||
const filteredModules = modules.map(module => ({
|
||||
...module,
|
||||
permissions: module.permissions.filter(perm =>
|
||||
perm.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
perm.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
})).filter(module => module.permissions.length > 0);
|
||||
|
||||
const enabledCount = Object.values(permissions).filter(Boolean).length;
|
||||
const totalCount = modules.reduce((sum, m) => sum + m.permissions.length, 0);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<PageHeader
|
||||
title="Permissions Management"
|
||||
subtitle="Configure access control for each layer in the KROW ecosystem"
|
||||
/>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-1.5 inline-flex gap-1 mb-6 shadow-sm">
|
||||
<button
|
||||
onClick={() => { setActiveTab("layers"); setEditingPosition(null); }}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === "layers"
|
||||
? "bg-gradient-to-r from-[#0A39DF] to-[#1C323E] text-white shadow-md"
|
||||
: "text-slate-600 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
Layer Permissions
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("positions")}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === "positions"
|
||||
? "bg-gradient-to-r from-[#0A39DF] to-[#1C323E] text-white shadow-md"
|
||||
: "text-slate-600 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<UserCog className="w-4 h-4" />
|
||||
Position Templates
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "positions" && !editingPosition && (
|
||||
<>
|
||||
{/* Position Templates */}
|
||||
<Card className="mb-4 border-slate-200 shadow-lg">
|
||||
<CardHeader className="bg-white border-b border-slate-200 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-[#0A39DF] flex items-center justify-center">
|
||||
<UserCog className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-slate-900 text-lg">Position Templates</CardTitle>
|
||||
<p className="text-sm text-slate-500">Define default permissions for job positions</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setShowAddPosition(true)} className="bg-[#0A39DF] hover:bg-[#0831b8]">
|
||||
<Plus className="w-4 h-4 mr-1" /> Add Position
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{positionTemplates.map((template) => {
|
||||
const layerConfig = LAYER_HIERARCHY.find(l => l.id === template.layer);
|
||||
const layerModules = PERMISSION_MODULES[template.layer] || [];
|
||||
|
||||
// Get permission names for display
|
||||
const permissionNames = template.permissions.map(permId => {
|
||||
for (const module of layerModules) {
|
||||
const found = module.permissions.find(p => p.id === permId);
|
||||
if (found) return found.name;
|
||||
}
|
||||
return permId;
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={template.id} className={`p-4 rounded-xl border-2 ${layerConfig?.borderColor || 'border-slate-200'} bg-white`}>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-900">{template.position}</h4>
|
||||
<Badge className={`text-[10px] mt-1 ${layerConfig?.textColor || ''} bg-white border`}>
|
||||
{layerConfig?.name || template.layer}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEditPositionPermissions(template)}>
|
||||
<Shield className="w-3.5 h-3.5 text-blue-600" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleDeletePosition(template.id)}>
|
||||
<Trash2 className="w-3.5 h-3.5 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 mb-2">{template.description}</p>
|
||||
<div className="mt-3 space-y-3">
|
||||
{layerModules.map((module) => {
|
||||
const ModuleIcon = module.icon;
|
||||
const moduleEnabledCount = module.permissions.filter(p => template.permissions.includes(p.id)).length;
|
||||
const allModuleEnabled = moduleEnabledCount === module.permissions.length;
|
||||
|
||||
return (
|
||||
<div key={module.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
{/* Module Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-slate-50 border-b border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-100 flex items-center justify-center">
|
||||
<ModuleIcon className="w-4 h-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{module.name}</p>
|
||||
<p className="text-[10px] text-slate-500">{moduleEnabledCount} of {module.permissions.length} enabled</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-slate-700"
|
||||
onClick={() => {
|
||||
const modulePermIds = module.permissions.map(p => p.id);
|
||||
let newPerms;
|
||||
if (allModuleEnabled) {
|
||||
newPerms = template.permissions.filter(p => !modulePermIds.includes(p));
|
||||
} else {
|
||||
newPerms = [...new Set([...template.permissions, ...modulePermIds])];
|
||||
}
|
||||
setPositionTemplates(positionTemplates.map(p =>
|
||||
p.id === template.id ? { ...p, permissions: newPerms } : p
|
||||
));
|
||||
toast({ title: allModuleEnabled ? "Module Disabled" : "Module Enabled", description: `${module.name} permissions updated` });
|
||||
}}
|
||||
>
|
||||
<XCircle className="w-3 h-3" />
|
||||
{allModuleEnabled ? 'Disable All' : 'Enable All'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Permissions List */}
|
||||
<div className="divide-y divide-slate-100">
|
||||
{module.permissions.map((perm) => {
|
||||
const isEnabled = template.permissions.includes(perm.id);
|
||||
return (
|
||||
<div
|
||||
key={perm.id}
|
||||
className="flex items-center justify-between px-4 py-2.5 hover:bg-slate-50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
const newPerms = isEnabled
|
||||
? template.permissions.filter(p => p !== perm.id)
|
||||
: [...template.permissions, perm.id];
|
||||
setPositionTemplates(positionTemplates.map(p =>
|
||||
p.id === template.id ? { ...p, permissions: newPerms } : p
|
||||
));
|
||||
toast({ title: isEnabled ? "Permission Disabled" : "Permission Enabled", description: `${perm.name} ${isEnabled ? 'removed from' : 'added to'} ${template.position}` });
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 rounded-md bg-slate-100 flex items-center justify-center">
|
||||
<Unlock className="w-3 h-3 text-slate-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-900">{perm.name}</p>
|
||||
<p className="text-[10px] text-slate-500">{perm.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`w-10 h-5 rounded-full p-0.5 transition-colors ${isEnabled ? 'bg-emerald-500' : 'bg-slate-300'}`}>
|
||||
<div className={`w-4 h-4 rounded-full bg-white shadow transition-transform ${isEnabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add Position Dialog */}
|
||||
<Dialog open={showAddPosition} onOpenChange={setShowAddPosition}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Position Template</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Position Title</label>
|
||||
<Input
|
||||
value={newPosition.position}
|
||||
onChange={(e) => setNewPosition({ ...newPosition, position: e.target.value })}
|
||||
placeholder="e.g., Regional Manager"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<Input
|
||||
value={newPosition.description}
|
||||
onChange={(e) => setNewPosition({ ...newPosition, description: e.target.value })}
|
||||
placeholder="Brief description of access level"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowAddPosition(false)}>Cancel</Button>
|
||||
<Button onClick={handleAddPosition} className="bg-[#0A39DF]">Create Position</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editingPosition && (
|
||||
<div className="mb-4 p-3 bg-purple-50 border border-purple-200 rounded-xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserCog className="w-5 h-5 text-purple-600" />
|
||||
<div>
|
||||
<p className="font-bold text-purple-900">Editing: {editingPosition.position}</p>
|
||||
<p className="text-xs text-purple-700">Configure permissions for this position template</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setEditingPosition(null)}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleSavePositionPermissions} className="bg-purple-600 hover:bg-purple-700">
|
||||
<Save className="w-4 h-4 mr-1" /> Save Position
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(activeTab === "layers" || editingPosition) && (
|
||||
<>
|
||||
{/* Layer Hierarchy Visual */}
|
||||
<Card className="mb-4 border-slate-200 shadow-lg overflow-hidden">
|
||||
<CardHeader className="bg-slate-800 text-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<Layers className="w-6 h-6" />
|
||||
<div>
|
||||
<CardTitle className="text-white">KROW Ecosystem Layers</CardTitle>
|
||||
<p className="text-slate-300 text-sm mt-1">Select a layer to configure its permissions</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 bg-gradient-to-b from-slate-100 to-white">
|
||||
{/* Horizontal Layer Flow */}
|
||||
<div className="flex items-center justify-center gap-2 pb-2 flex-wrap">
|
||||
{visibleLayers.map((layer, index) => {
|
||||
const Icon = layer.icon;
|
||||
const isSelected = effectiveLayer === layer.id;
|
||||
|
||||
return (
|
||||
<React.Fragment key={layer.id}>
|
||||
<button
|
||||
onClick={() => setSelectedLayer(layer.id)}
|
||||
className={`flex-shrink-0 relative group transition-all duration-200 ${
|
||||
isSelected ? 'scale-105 z-10' : 'hover:scale-102'
|
||||
} ${userRole !== "admin" ? 'cursor-default' : ''}`}
|
||||
disabled={userRole !== "admin"}
|
||||
>
|
||||
<div className={`
|
||||
w-16 h-16 md:w-20 md:h-20 rounded-xl flex flex-col items-center justify-center gap-1
|
||||
transition-all duration-200 border-2
|
||||
${isSelected
|
||||
? `bg-[#0A39DF] text-white shadow-lg border-transparent`
|
||||
: `${layer.bgColor} ${layer.borderColor} ${layer.textColor} hover:shadow-sm`
|
||||
}
|
||||
`}>
|
||||
<Icon className={`w-5 h-5 md:w-6 md:h-6 ${isSelected ? 'text-white' : ''}`} />
|
||||
<span className="text-[10px] md:text-xs font-bold text-center px-1 leading-tight">{layer.name}</span>
|
||||
</div>
|
||||
</button>
|
||||
{index < visibleLayers.length - 1 && (
|
||||
<div className="flex-shrink-0 w-4 md:w-6 h-0.5 bg-slate-300"></div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Selected Layer Info */}
|
||||
{selectedLayerConfig && (
|
||||
<div className={`mt-4 p-3 rounded-lg ${selectedLayerConfig.bgColor} ${selectedLayerConfig.borderColor} border`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-8 h-8 rounded-lg bg-[#0A39DF] flex items-center justify-center`}>
|
||||
<selectedLayerConfig.icon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`font-bold text-sm ${selectedLayerConfig.textColor}`}>
|
||||
{selectedLayerConfig.name} Layer
|
||||
</h3>
|
||||
<p className="text-xs text-slate-600">{selectedLayerConfig.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold text-slate-900">{enabledCount}/{totalCount}</p>
|
||||
<p className="text-[10px] text-slate-500">enabled</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search permissions..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 h-10 bg-white border-slate-300 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permission Modules */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 max-h-[calc(100vh-480px)] overflow-y-auto pb-4">
|
||||
{filteredModules.map((module) => {
|
||||
const Icon = module.icon;
|
||||
const isExpanded = expandedModules[module.id] !== false;
|
||||
const moduleEnabled = module.permissions.filter(p => permissions[p.id]).length;
|
||||
const moduleTotal = module.permissions.length;
|
||||
const allEnabled = moduleEnabled === moduleTotal;
|
||||
|
||||
return (
|
||||
<Card key={module.id} className="border-slate-200 shadow-md overflow-hidden">
|
||||
<CardHeader
|
||||
className={`cursor-pointer transition-colors ${
|
||||
isExpanded ? 'bg-slate-100' : 'bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
onClick={() => toggleModule(module.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-5 h-5 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
<div className={`w-10 h-10 rounded-xl bg-[#0A39DF] flex items-center justify-center`}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{module.name}</CardTitle>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
{moduleEnabled} of {moduleTotal} enabled
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleAllInModule(module, !allEnabled)}
|
||||
className="text-xs"
|
||||
>
|
||||
{allEnabled ? (
|
||||
<><XCircle className="w-4 h-4 mr-1" /> Disable All</>
|
||||
) : (
|
||||
<><CheckCircle2 className="w-4 h-4 mr-1" /> Enable All</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle all permissions in this module</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent className="p-0 divide-y divide-slate-100">
|
||||
{module.permissions.map((perm) => {
|
||||
const isEnabled = permissions[perm.id];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={perm.id}
|
||||
className={`flex items-center justify-between p-4 transition-colors ${
|
||||
isEnabled ? 'bg-white' : 'bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
|
||||
isEnabled ? 'bg-green-100' : 'bg-slate-200'
|
||||
}`}>
|
||||
{isEnabled ? (
|
||||
<Unlock className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Lock className="w-4 h-4 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-medium ${isEnabled ? 'text-slate-900' : 'text-slate-500'}`}>
|
||||
{perm.name}
|
||||
</span>
|
||||
{perm.critical && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Critical permission - use with caution</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">{perm.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={() => togglePermission(perm.id)}
|
||||
className="data-[state=checked]:bg-green-600"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Save Footer */}
|
||||
{!editingPosition && (
|
||||
<div className="mt-4 p-4 bg-white rounded-xl border border-slate-200 shadow-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-10 h-10 rounded-xl bg-[#0A39DF] flex items-center justify-center`}>
|
||||
<selectedLayerConfig.icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-slate-900 text-sm">{selectedLayerConfig.name} Layer</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{enabledCount} enabled • {totalCount - enabledCount} disabled
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="border-slate-300">
|
||||
Reset
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} className="bg-[#0A39DF] hover:bg-[#0831b8]">
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
2994
frontend-web-free/src/pages/ProcurementDashboard.jsx
Normal file
2994
frontend-web-free/src/pages/ProcurementDashboard.jsx
Normal file
File diff suppressed because it is too large
Load Diff
508
frontend-web-free/src/pages/RapidOrder.jsx
Normal file
508
frontend-web-free/src/pages/RapidOrder.jsx
Normal file
@@ -0,0 +1,508 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Zap, Send, Check, Edit3, MapPin, Clock, Users, AlertCircle, Sparkles, Mic, X, Calendar as CalendarIcon, ArrowLeft } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { format } from "date-fns";
|
||||
|
||||
// Helper function to convert 24-hour time to 12-hour format
|
||||
const convertTo12Hour = (time24) => {
|
||||
if (!time24 || time24 === "—") return time24;
|
||||
|
||||
try {
|
||||
const parts = time24.split(':');
|
||||
if (!parts || parts.length < 2) return time24;
|
||||
|
||||
const hours = parseInt(parts[0], 10);
|
||||
const minutes = parseInt(parts[1], 10);
|
||||
|
||||
if (isNaN(hours) || isNaN(minutes)) return time24;
|
||||
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const hours12 = hours % 12 || 12;
|
||||
const minutesStr = minutes.toString().padStart(2, '0');
|
||||
|
||||
return `${hours12}:${minutesStr} ${period}`;
|
||||
} catch (error) {
|
||||
console.error('Error converting time:', error);
|
||||
return time24;
|
||||
}
|
||||
};
|
||||
|
||||
export default function RapidOrder() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [message, setMessage] = useState("");
|
||||
const [conversation, setConversation] = useState([]);
|
||||
const [detectedOrder, setDetectedOrder] = useState(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [submissionTime, setSubmissionTime] = useState(null);
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-rapid'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: businesses } = useQuery({
|
||||
queryKey: ['user-businesses'],
|
||||
queryFn: () => base44.entities.Business.filter({ contact_name: user?.full_name }),
|
||||
enabled: !!user,
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const createRapidOrderMutation = useMutation({
|
||||
mutationFn: (orderData) => base44.entities.Event.create(orderData),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['client-events'] });
|
||||
|
||||
const now = new Date();
|
||||
setSubmissionTime(now);
|
||||
|
||||
toast({
|
||||
title: "✅ RAPID Order Created",
|
||||
description: "Order sent to preferred vendor with priority notification",
|
||||
});
|
||||
|
||||
// Show success message in chat
|
||||
setConversation(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: `🚀 **Order Submitted Successfully!**\n\nOrder Number: **${data.id?.slice(-8) || 'RAPID-001'}**\nSubmitted: **${format(now, 'h:mm:ss a')}**\n\nYour preferred vendor has been notified and will assign staff shortly.`,
|
||||
isSuccess: true
|
||||
}]);
|
||||
|
||||
// Reset after delay
|
||||
setTimeout(() => {
|
||||
navigate(createPageUrl("ClientDashboard"));
|
||||
}, 3000);
|
||||
},
|
||||
});
|
||||
|
||||
const analyzeMessage = async (msg) => {
|
||||
setIsProcessing(true);
|
||||
|
||||
setConversation(prev => [...prev, { role: 'user', content: msg }]);
|
||||
|
||||
try {
|
||||
const response = await base44.integrations.Core.InvokeLLM({
|
||||
prompt: `You are an order assistant. Analyze this message and extract order details:
|
||||
|
||||
Message: "${msg}"
|
||||
Current user: ${user?.full_name}
|
||||
User's locations: ${businesses.map(b => b.business_name).join(', ')}
|
||||
|
||||
Extract:
|
||||
1. Urgency keywords (ASAP, today, emergency, call out, urgent, rapid, now)
|
||||
2. Role/position needed (cook, bartender, server, dishwasher, etc.)
|
||||
3. Number of staff (if mentioned, parse the number correctly - e.g., "5 cooks" = 5, "need 3 servers" = 3)
|
||||
4. End time (if mentioned, extract the time - e.g., "until 5am" = "05:00", "until 11pm" = "23:00", "until midnight" = "00:00")
|
||||
5. Location (if mentioned, otherwise use first available location)
|
||||
|
||||
IMPORTANT:
|
||||
- Make sure to correctly extract the number of staff from phrases like "need 5 cooks" or "I need 3 bartenders"
|
||||
- If end time is mentioned (e.g., "until 5am", "till 11pm"), extract it in 24-hour format (e.g., "05:00", "23:00")
|
||||
- If no end time is mentioned, leave it as null
|
||||
|
||||
Return a concise summary.`,
|
||||
response_json_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
is_urgent: { type: "boolean" },
|
||||
role: { type: "string" },
|
||||
count: { type: "number" },
|
||||
location: { type: "string" },
|
||||
end_time: { type: "string" }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const parsed = response;
|
||||
const primaryLocation = businesses[0]?.business_name || "Primary Location";
|
||||
|
||||
// Ensure count is properly set - default to 1 if not detected
|
||||
// CRITICAL: For RAPID orders, use the EXACT count parsed, no modifications
|
||||
const staffCount = parsed.count && parsed.count > 0 ? Math.floor(parsed.count) : 1;
|
||||
|
||||
// Get current time for start_time (when ASAP)
|
||||
const now = new Date();
|
||||
const currentTime = format(now, 'HH:mm');
|
||||
|
||||
// Handle end_time - use parsed end time or current time as confirmation time
|
||||
const endTime = parsed.end_time || currentTime;
|
||||
|
||||
const order = {
|
||||
is_rapid: parsed.is_urgent || true,
|
||||
role: parsed.role || "Staff Member",
|
||||
count: staffCount,
|
||||
location: parsed.location || primaryLocation,
|
||||
start_time: currentTime, // Always use current time for ASAP orders (24-hour format for storage)
|
||||
end_time: endTime, // Use parsed end time or current time (24-hour format for storage)
|
||||
start_time_display: convertTo12Hour(currentTime), // For display
|
||||
end_time_display: convertTo12Hour(endTime), // For display
|
||||
business_name: primaryLocation,
|
||||
hub: businesses[0]?.hub_building || "Main Hub",
|
||||
submission_time: now // Store the actual submission time
|
||||
};
|
||||
|
||||
setDetectedOrder(order);
|
||||
|
||||
const aiMessage = `Is this a RAPID ORDER for **${order.count} ${order.role}${order.count > 1 ? 's' : ''}** at **${order.location}**?\n\nStart Time: ${order.start_time_display}\nEnd Time: ${order.end_time_display}`;
|
||||
|
||||
setConversation(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: aiMessage,
|
||||
showConfirm: true
|
||||
}]);
|
||||
|
||||
} catch (error) {
|
||||
setConversation(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: "I couldn't process that. Please provide more details like: role needed, how many, and when."
|
||||
}]);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = () => {
|
||||
if (!message.trim()) return;
|
||||
analyzeMessage(message);
|
||||
setMessage("");
|
||||
};
|
||||
|
||||
const handleVoiceInput = () => {
|
||||
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
|
||||
toast({
|
||||
title: "Voice not supported",
|
||||
description: "Your browser doesn't support voice input",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const recognition = new SpeechRecognition();
|
||||
|
||||
recognition.onstart = () => setIsListening(true);
|
||||
recognition.onend = () => setIsListening(false);
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
const transcript = event.results[0][0].transcript;
|
||||
setMessage(transcript);
|
||||
analyzeMessage(transcript);
|
||||
};
|
||||
|
||||
recognition.onerror = () => {
|
||||
setIsListening(false);
|
||||
toast({
|
||||
title: "Voice input failed",
|
||||
description: "Please try typing instead",
|
||||
variant: "destructive",
|
||||
});
|
||||
};
|
||||
|
||||
recognition.start();
|
||||
};
|
||||
|
||||
const handleConfirmOrder = () => {
|
||||
if (!detectedOrder) return;
|
||||
|
||||
const now = new Date();
|
||||
const confirmTime = format(now, 'HH:mm');
|
||||
const confirmTime12Hour = convertTo12Hour(confirmTime);
|
||||
|
||||
// Create comprehensive order data with proper requested field and actual times
|
||||
// CRITICAL: For RAPID orders, requested must exactly match the count - no additions
|
||||
const exactCount = Math.floor(Number(detectedOrder.count));
|
||||
const orderData = {
|
||||
event_name: `RAPID: ${exactCount} ${detectedOrder.role}${exactCount > 1 ? 's' : ''}`,
|
||||
is_rapid: true,
|
||||
status: "Pending",
|
||||
business_name: detectedOrder.business_name,
|
||||
hub: detectedOrder.hub,
|
||||
event_location: detectedOrder.location,
|
||||
date: now.toISOString().split('T')[0],
|
||||
requested: exactCount, // EXACT count requested, no modifications
|
||||
client_name: user?.full_name,
|
||||
client_email: user?.email,
|
||||
notes: `RAPID ORDER - Submitted at ${detectedOrder.start_time_display} - Confirmed at ${confirmTime12Hour}\nStart: ${detectedOrder.start_time_display} | End: ${detectedOrder.end_time_display}`,
|
||||
shifts: [{
|
||||
shift_name: "Emergency Shift",
|
||||
location: detectedOrder.location,
|
||||
roles: [{
|
||||
role: detectedOrder.role,
|
||||
count: exactCount, // Use exact count, no modifications
|
||||
start_time: detectedOrder.start_time, // Store in 24-hour format
|
||||
end_time: detectedOrder.end_time // Store in 24-hour format
|
||||
}]
|
||||
}]
|
||||
};
|
||||
|
||||
console.log('Creating RAPID order with data:', orderData); // Debug log
|
||||
|
||||
createRapidOrderMutation.mutate(orderData);
|
||||
};
|
||||
|
||||
const handleEditOrder = () => {
|
||||
setConversation(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: "Please describe what you'd like to change."
|
||||
}]);
|
||||
setDetectedOrder(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-red-50 via-orange-50 to-yellow-50 p-6">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(createPageUrl("ClientDashboard"))}
|
||||
className="hover:bg-white/50"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-orange-500 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Zap className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-red-700 flex items-center gap-2">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
RAPID Order
|
||||
</h1>
|
||||
<p className="text-sm text-red-600 mt-1">Emergency staffing in minutes</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600 mb-1">
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
<span>{format(new Date(), 'EEEE, MMMM d, yyyy')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{format(new Date(), 'h:mm a')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-white border-2 border-red-300 shadow-2xl">
|
||||
<CardHeader className="border-b border-red-200 bg-gradient-to-r from-red-50 to-orange-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg font-bold text-red-700">
|
||||
Tell us what you need
|
||||
</CardTitle>
|
||||
<Badge className="bg-red-600 text-white font-bold text-sm px-4 py-2 shadow-md animate-pulse">
|
||||
URGENT
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6">
|
||||
{/* Chat Messages */}
|
||||
<div className="space-y-4 mb-6 max-h-[500px] overflow-y-auto">
|
||||
{conversation.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-red-500 to-orange-500 rounded-2xl flex items-center justify-center shadow-2xl">
|
||||
<Zap className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-2xl text-slate-900 mb-3">Need staff urgently?</h3>
|
||||
<p className="text-base text-slate-600 mb-6">Type or speak what you need, I'll handle the rest</p>
|
||||
<div className="text-left max-w-lg mx-auto space-y-3">
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 p-4 rounded-xl border-2 border-blue-200 text-sm">
|
||||
<strong className="text-blue-900">Example:</strong> <span className="text-slate-700">"We had a call out. Need 2 cooks ASAP"</span>
|
||||
</div>
|
||||
<div className="bg-gradient-to-r from-purple-50 to-pink-50 p-4 rounded-xl border-2 border-purple-200 text-sm">
|
||||
<strong className="text-purple-900">Example:</strong> <span className="text-slate-700">"Need 5 bartenders ASAP until 5am"</span>
|
||||
</div>
|
||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 p-4 rounded-xl border-2 border-green-200 text-sm">
|
||||
<strong className="text-green-900">Example:</strong> <span className="text-slate-700">"Emergency! Need 3 servers right now till midnight"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{conversation.map((msg, idx) => (
|
||||
<motion.div
|
||||
key={idx}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`max-w-[85%] ${
|
||||
msg.role === 'user'
|
||||
? 'bg-gradient-to-br from-blue-600 to-blue-700 text-white'
|
||||
: msg.isSuccess
|
||||
? 'bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300'
|
||||
: 'bg-white border-2 border-red-200'
|
||||
} rounded-2xl p-5 shadow-lg`}>
|
||||
{msg.role === 'assistant' && !msg.isSuccess && (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-7 h-7 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-red-600">AI Assistant</span>
|
||||
</div>
|
||||
)}
|
||||
<p className={`text-base whitespace-pre-line ${
|
||||
msg.role === 'user' ? 'text-white' :
|
||||
msg.isSuccess ? 'text-green-900' :
|
||||
'text-slate-900'
|
||||
}`}>
|
||||
{msg.content}
|
||||
</p>
|
||||
|
||||
{msg.showConfirm && detectedOrder && (
|
||||
<div className="mt-5 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 p-4 bg-gradient-to-br from-slate-50 to-blue-50 rounded-xl border-2 border-blue-300">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 font-semibold">Staff Needed</p>
|
||||
<p className="font-bold text-base text-slate-900">{detectedOrder.count} {detectedOrder.role}{detectedOrder.count > 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<MapPin className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 font-semibold">Location</p>
|
||||
<p className="font-bold text-base text-slate-900">{detectedOrder.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 col-span-2">
|
||||
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<Clock className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 font-semibold">Time</p>
|
||||
<p className="font-bold text-base text-slate-900">
|
||||
Start: {detectedOrder.start_time_display} | End: {detectedOrder.end_time_display}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleConfirmOrder}
|
||||
disabled={createRapidOrderMutation.isPending}
|
||||
className="flex-1 bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-xl text-base py-6"
|
||||
>
|
||||
<Check className="w-5 h-5 mr-2" />
|
||||
{createRapidOrderMutation.isPending ? "Creating..." : "CONFIRM & SEND"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEditOrder}
|
||||
variant="outline"
|
||||
className="border-2 border-red-300 hover:bg-red-50 text-base py-6"
|
||||
>
|
||||
<Edit3 className="w-5 h-5 mr-2" />
|
||||
EDIT
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{isProcessing && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex justify-start"
|
||||
>
|
||||
<div className="bg-white border-2 border-red-200 rounded-2xl p-5 shadow-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-7 h-7 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center animate-pulse">
|
||||
<Sparkles className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="text-base text-slate-600">Processing your request...</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
placeholder="Type or speak... (e.g., 'Need 5 cooks ASAP until 5am')"
|
||||
className="flex-1 border-2 border-red-300 focus:border-red-500 text-base resize-none"
|
||||
rows={3}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleVoiceInput}
|
||||
disabled={isProcessing || isListening}
|
||||
variant="outline"
|
||||
className={`border-2 ${isListening ? 'border-red-500 bg-red-50' : 'border-red-300'} hover:bg-red-50 text-base py-6 px-6`}
|
||||
>
|
||||
<Mic className={`w-5 h-5 mr-2 ${isListening ? 'animate-pulse text-red-600' : ''}`} />
|
||||
{isListening ? 'Listening...' : 'Speak'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!message.trim() || isProcessing}
|
||||
className="flex-1 bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-xl text-base py-6"
|
||||
>
|
||||
<Send className="w-5 h-5 mr-2" />
|
||||
Send Message
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Helper Text */}
|
||||
<div className="mt-4 p-4 bg-blue-50 border-2 border-blue-200 rounded-xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<strong>Tip:</strong> Include role, quantity, and urgency for fastest processing.
|
||||
Optionally add end time like "until 5am" or "till midnight".
|
||||
AI will auto-detect your location and send to your preferred vendor with priority notification.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
frontend-web-free/src/pages/Register.jsx
Normal file
128
frontend-web-free/src/pages/Register.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { createUserWithEmailAndPassword } from "firebase/auth";
|
||||
import { auth } from "@/firebase";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function Register() {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const validatePassword = (password) => {
|
||||
if (password.length < 6) {
|
||||
return "Password must be at least 6 characters long.";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateEmail = (email) => {
|
||||
const re =
|
||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
if (!re.test(String(email).toLowerCase())) {
|
||||
return "Invalid email address.";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleRegister = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!email || !password) {
|
||||
setError("Email and password are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const emailError = validateEmail(email);
|
||||
if (emailError) {
|
||||
setError(emailError);
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordError = validatePassword(password);
|
||||
if (passwordError) {
|
||||
setError(passwordError);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await createUserWithEmailAndPassword(auth, email, password);
|
||||
navigate("/");
|
||||
} catch (error) {
|
||||
setError(error.message || "Something went wrong. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<Card className="w-[350px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Register</CardTitle>
|
||||
<CardDescription>Create a new account.</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={handleRegister}>
|
||||
<div className="grid w-full items-center gap-4">
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex-col">
|
||||
<Button onClick={handleRegister} disabled={loading} className="w-full">
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Register"}
|
||||
</Button>
|
||||
|
||||
<p className="mt-4 text-sm text-slate-600">
|
||||
Already have an account?{" "}
|
||||
<Link to="/login" className="text-blue-600 hover:underline">
|
||||
Login
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
frontend-web-free/src/pages/Reports.jsx
Normal file
183
frontend-web-free/src/pages/Reports.jsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, FileText, TrendingUp, Users, DollarSign, Zap } from "lucide-react";
|
||||
import StaffingCostReport from "../components/reports/StaffingCostReport";
|
||||
import StaffPerformanceReport from "../components/reports/StaffPerformanceReport";
|
||||
import ClientTrendsReport from "../components/reports/ClientTrendsReport";
|
||||
import OperationalEfficiencyReport from "../components/reports/OperationalEfficiencyReport";
|
||||
import CustomReportBuilder from "../components/reports/CustomReportBuilder";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
export default function Reports() {
|
||||
const [activeTab, setActiveTab] = useState("costs");
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: events = [] } = useQuery({
|
||||
queryKey: ['events-reports'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: staff = [] } = useQuery({
|
||||
queryKey: ['staff-reports'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: invoices = [] } = useQuery({
|
||||
queryKey: ['invoices-reports'],
|
||||
queryFn: () => base44.entities.Invoice.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const handleExportAll = () => {
|
||||
const data = {
|
||||
events,
|
||||
staff,
|
||||
invoices,
|
||||
generated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `krow-full-report-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast({ title: "✅ Report Exported", description: "Full report downloaded successfully" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Reports & Analytics</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Comprehensive insights into staffing, costs, and performance
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleExportAll} className="bg-[#0A39DF]">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export All Data
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card className="border-blue-200 bg-blue-50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-blue-600 font-semibold uppercase">Total Events</p>
|
||||
<p className="text-2xl font-bold text-blue-700">{events.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-green-200 bg-green-50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-green-600 font-semibold uppercase">Active Staff</p>
|
||||
<p className="text-2xl font-bold text-green-700">{staff.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-purple-200 bg-purple-50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-500 rounded-lg flex items-center justify-center">
|
||||
<DollarSign className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-purple-600 font-semibold uppercase">Total Revenue</p>
|
||||
<p className="text-2xl font-bold text-purple-700">
|
||||
${invoices.reduce((sum, inv) => sum + (inv.amount || 0), 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-amber-200 bg-amber-50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
|
||||
<Zap className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-amber-600 font-semibold uppercase">Automation</p>
|
||||
<p className="text-2xl font-bold text-amber-700">85%</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Report Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="bg-white border">
|
||||
<TabsTrigger value="costs">
|
||||
<DollarSign className="w-4 h-4 mr-2" />
|
||||
Staffing Costs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="performance">
|
||||
<TrendingUp className="w-4 h-4 mr-2" />
|
||||
Staff Performance
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="clients">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
Client Trends
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="efficiency">
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Operational Efficiency
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="custom">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Custom Reports
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="costs" className="mt-6">
|
||||
<StaffingCostReport events={events} invoices={invoices} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="performance" className="mt-6">
|
||||
<StaffPerformanceReport staff={staff} events={events} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="clients" className="mt-6">
|
||||
<ClientTrendsReport events={events} invoices={invoices} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="efficiency" className="mt-6">
|
||||
<OperationalEfficiencyReport events={events} staff={staff} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="custom" className="mt-6">
|
||||
<CustomReportBuilder events={events} staff={staff} invoices={invoices} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
252
frontend-web-free/src/pages/Schedule.jsx
Normal file
252
frontend-web-free/src/pages/Schedule.jsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ChevronLeft, ChevronRight, Plus, Clock, DollarSign, Calendar as CalendarIcon } from "lucide-react";
|
||||
import { format, startOfWeek, addDays, isSameDay, addWeeks, subWeeks, isToday, parseISO } from "date-fns";
|
||||
|
||||
const safeParseDate = (dateString) => {
|
||||
if (!dateString) return null;
|
||||
try {
|
||||
if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||
const [year, month, day] = dateString.split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
return parseISO(dateString);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default function Schedule() {
|
||||
const navigate = useNavigate();
|
||||
const [currentWeek, setCurrentWeek] = useState(startOfWeek(new Date(), { weekStartsOn: 0 }));
|
||||
|
||||
const { data: events = [] } = useQuery({
|
||||
queryKey: ['events'],
|
||||
queryFn: () => base44.entities.Event.list('-date'),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeek, i));
|
||||
|
||||
const getEventsForDay = (date) => {
|
||||
return events.filter(event => {
|
||||
const eventDate = safeParseDate(event.date);
|
||||
return eventDate && isSameDay(eventDate, date);
|
||||
});
|
||||
};
|
||||
|
||||
const calculateWeekMetrics = () => {
|
||||
const weekEvents = events.filter(event => {
|
||||
const eventDate = safeParseDate(event.date);
|
||||
if (!eventDate) return false;
|
||||
return weekDays.some(day => isSameDay(eventDate, day));
|
||||
});
|
||||
|
||||
const totalHours = weekEvents.reduce((sum, event) => {
|
||||
const hours = event.shifts?.reduce((shiftSum, shift) => {
|
||||
return shiftSum + (shift.roles?.reduce((roleSum, role) => roleSum + (role.hours || 0), 0) || 0);
|
||||
}, 0) || 0;
|
||||
return sum + hours;
|
||||
}, 0);
|
||||
|
||||
const totalCost = weekEvents.reduce((sum, event) => sum + (event.total || 0), 0);
|
||||
const totalShifts = weekEvents.reduce((sum, event) => sum + (event.shifts?.length || 0), 0);
|
||||
|
||||
return { totalHours, totalCost, totalShifts };
|
||||
};
|
||||
|
||||
const metrics = calculateWeekMetrics();
|
||||
|
||||
const goToPreviousWeek = () => setCurrentWeek(subWeeks(currentWeek, 1));
|
||||
const goToNextWeek = () => setCurrentWeek(addWeeks(currentWeek, 1));
|
||||
const goToToday = () => setCurrentWeek(startOfWeek(new Date(), { weekStartsOn: 0 }));
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-[1800px] mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900">Schedule</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Plan and manage staff shifts</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate(createPageUrl('CreateEvent'))}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Shift
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Metrics Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<Card className="border border-blue-200 bg-blue-50">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-blue-600 font-medium">Week Total Hours</p>
|
||||
<p className="text-4xl font-bold text-blue-900 mt-2">{metrics.totalHours.toFixed(1)}</p>
|
||||
</div>
|
||||
<Clock className="w-10 h-10 text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-green-200 bg-green-50">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-green-600 font-medium">Week Labor Cost</p>
|
||||
<p className="text-4xl font-bold text-green-900 mt-2">${metrics.totalCost.toLocaleString()}</p>
|
||||
</div>
|
||||
<DollarSign className="w-10 h-10 text-green-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-teal-200 bg-teal-50">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-teal-600 font-medium">Total Shifts</p>
|
||||
<p className="text-4xl font-bold text-teal-900 mt-2">{metrics.totalShifts}</p>
|
||||
</div>
|
||||
<CalendarIcon className="w-10 h-10 text-teal-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Week Navigation */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" size="icon" onClick={goToPreviousWeek}>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-slate-500">Week of</p>
|
||||
<p className="text-lg font-bold text-slate-900">{format(currentWeek, 'MMM d, yyyy')}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={goToNextWeek}>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button variant="outline" onClick={goToToday}>
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Weekly Calendar */}
|
||||
<div className="grid grid-cols-7 gap-3">
|
||||
{weekDays.map((day, index) => {
|
||||
const dayEvents = getEventsForDay(day);
|
||||
const isTodayDay = isToday(day);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
className={`${isTodayDay ? 'bg-gradient-to-br from-blue-500 to-teal-500 text-white border-blue-600' : 'bg-white border-slate-200'}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
{/* Day Header */}
|
||||
<div className="text-center mb-4">
|
||||
<p className={`text-xs font-medium ${isTodayDay ? 'text-white/80' : 'text-slate-500'}`}>
|
||||
{format(day, 'EEE')}
|
||||
</p>
|
||||
<p className={`text-2xl font-bold ${isTodayDay ? 'text-white' : 'text-slate-900'}`}>
|
||||
{format(day, 'd')}
|
||||
</p>
|
||||
<p className={`text-xs ${isTodayDay ? 'text-white/80' : 'text-slate-500'}`}>
|
||||
{format(day, 'MMM')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Add Shift Button */}
|
||||
<Button
|
||||
variant={isTodayDay ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
className={`w-full mb-4 ${isTodayDay ? 'bg-white/20 hover:bg-white/30 text-white border-white/40' : ''}`}
|
||||
onClick={() => navigate(createPageUrl('CreateEvent'))}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Add Shift
|
||||
</Button>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="space-y-2">
|
||||
{dayEvents.length === 0 ? (
|
||||
<p className={`text-xs text-center ${isTodayDay ? 'text-white/70' : 'text-slate-400'}`}>
|
||||
No shifts
|
||||
</p>
|
||||
) : (
|
||||
dayEvents.map((event) => {
|
||||
const firstShift = event.shifts?.[0];
|
||||
const firstRole = firstShift?.roles?.[0];
|
||||
const firstStaff = event.assigned_staff?.[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={() => navigate(createPageUrl(`EventDetail?id=${event.id}`))}
|
||||
className={`p-3 rounded cursor-pointer transition-all ${
|
||||
isTodayDay
|
||||
? 'bg-white/20 hover:bg-white/30 border border-white/40'
|
||||
: 'bg-white hover:bg-slate-50 border border-slate-200 shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{/* Status Badges */}
|
||||
<div className="flex gap-1 mb-2 flex-wrap">
|
||||
{firstRole?.role && (
|
||||
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 text-[10px] font-medium rounded">
|
||||
{firstRole.role}
|
||||
</span>
|
||||
)}
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-medium rounded">
|
||||
{event.status || 'scheduled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Staff Member */}
|
||||
{firstStaff && (
|
||||
<p className={`text-xs font-semibold mb-1 flex items-center gap-1 ${isTodayDay ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span className="text-[10px]">👤</span>
|
||||
{firstStaff.staff_name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Time */}
|
||||
{firstRole && (firstRole.start_time || firstRole.end_time) && (
|
||||
<p className={`text-[10px] mb-1 flex items-center gap-1 ${isTodayDay ? 'text-white/80' : 'text-slate-500'}`}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{firstRole.start_time || '00:00'} - {firstRole.end_time || '00:00'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Cost */}
|
||||
{event.total > 0 && (
|
||||
<p className={`text-xs font-bold mt-2 ${isTodayDay ? 'text-white' : 'text-slate-900'}`}>
|
||||
${event.total.toFixed(2)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
frontend-web-free/src/pages/SectorManagement.jsx
Normal file
141
frontend-web-free/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
frontend-web-free/src/pages/Settings.jsx
Normal file
19
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
36
frontend-web-free/src/pages/SmartScheduler.jsx
Normal file
36
frontend-web-free/src/pages/SmartScheduler.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowRight, Sparkles } from "lucide-react";
|
||||
|
||||
export default function SmartScheduler() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen flex items-center justify-center">
|
||||
<Card className="max-w-2xl w-full">
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Sparkles className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 mb-4">
|
||||
Smart Scheduling is Now Part of Orders
|
||||
</h1>
|
||||
<p className="text-lg text-slate-600 mb-8">
|
||||
All smart assignment, automation, and scheduling features have been unified into the main Order Management view for a consistent experience.
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => navigate(createPageUrl("Events"))}
|
||||
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
|
||||
>
|
||||
Go to Order Management
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2888
frontend-web-free/src/pages/SmartVendorOnboarding.jsx
Normal file
2888
frontend-web-free/src/pages/SmartVendorOnboarding.jsx
Normal file
File diff suppressed because it is too large
Load Diff
469
frontend-web-free/src/pages/StaffAvailability.jsx
Normal file
469
frontend-web-free/src/pages/StaffAvailability.jsx
Normal file
@@ -0,0 +1,469 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Users, Calendar, Clock, TrendingUp, TrendingDown, AlertCircle, CheckCircle, XCircle, Search, Filter, List, LayoutGrid, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export default function StaffAvailability() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState("all");
|
||||
const [filterUtilization, setFilterUtilization] = useState("all");
|
||||
const [viewMode, setViewMode] = useState("cards");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(50);
|
||||
const [sortBy, setSortBy] = useState("need_work_index");
|
||||
|
||||
const { data: allStaff = [] } = useQuery({
|
||||
queryKey: ['staff-availability-all'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: availabilityData = [] } = useQuery({
|
||||
queryKey: ['worker-availability'],
|
||||
queryFn: () => base44.entities.WorkerAvailability.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: events = [] } = useQuery({
|
||||
queryKey: ['events-for-availability'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
// Calculate metrics
|
||||
const metrics = useMemo(() => {
|
||||
const needsWork = availabilityData.filter(w => w.need_work_index >= 60).length;
|
||||
const fullyBooked = availabilityData.filter(w => w.utilization_percentage >= 90).length;
|
||||
const hasUtilization = availabilityData.filter(w => w.utilization_percentage > 0 && w.utilization_percentage < 90).length;
|
||||
const onTimeOff = availabilityData.filter(w => w.availability_status === 'BLOCKED').length;
|
||||
|
||||
return { needsWork, fullyBooked, hasUtilization, onTimeOff };
|
||||
}, [availabilityData]);
|
||||
|
||||
// Filter and search logic
|
||||
const filteredAvailability = useMemo(() => {
|
||||
let filtered = availabilityData;
|
||||
|
||||
// Search
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(a =>
|
||||
a.staff_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (filterStatus !== "all") {
|
||||
filtered = filtered.filter(a => a.availability_status === filterStatus);
|
||||
}
|
||||
|
||||
// Utilization filter
|
||||
if (filterUtilization === "underutilized") {
|
||||
filtered = filtered.filter(a => a.utilization_percentage < 50);
|
||||
} else if (filterUtilization === "optimal") {
|
||||
filtered = filtered.filter(a => a.utilization_percentage >= 50 && a.utilization_percentage < 100);
|
||||
} else if (filterUtilization === "full") {
|
||||
filtered = filtered.filter(a => a.utilization_percentage >= 100);
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (sortBy === "need_work_index") {
|
||||
filtered.sort((a, b) => (b.need_work_index || 0) - (a.need_work_index || 0));
|
||||
} else if (sortBy === "utilization") {
|
||||
filtered.sort((a, b) => (a.utilization_percentage || 0) - (b.utilization_percentage || 0));
|
||||
} else if (sortBy === "name") {
|
||||
filtered.sort((a, b) => (a.staff_name || "").localeCompare(b.staff_name || ""));
|
||||
} else if (sortBy === "availability_score") {
|
||||
filtered.sort((a, b) => (b.predicted_availability_score || 0) - (a.predicted_availability_score || 0));
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [availabilityData, searchTerm, filterStatus, filterUtilization, sortBy]);
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(filteredAvailability.length / itemsPerPage);
|
||||
const paginatedData = filteredAvailability.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, filterStatus, filterUtilization, sortBy]);
|
||||
|
||||
const getUtilizationColor = (percentage) => {
|
||||
if (percentage === 0) return "text-slate-400";
|
||||
if (percentage < 50) return "text-red-600";
|
||||
if (percentage < 80) return "text-amber-600";
|
||||
return "text-green-600";
|
||||
};
|
||||
|
||||
const getStatusBadge = (worker) => {
|
||||
const statusConfig = {
|
||||
'CONFIRMED_AVAILABLE': { bg: 'bg-green-100', text: 'text-green-800', label: 'Available' },
|
||||
'UNKNOWN': { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Unknown' },
|
||||
'BLOCKED': { bg: 'bg-red-100', text: 'text-red-800', label: 'Unavailable' },
|
||||
};
|
||||
const config = statusConfig[worker.availability_status] || statusConfig['UNKNOWN'];
|
||||
return <Badge className={`${config.bg} ${config.text} text-[10px]`}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-[1800px] mx-auto space-y-6">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Staff Availability</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Showing {filteredAvailability.length} of {availabilityData.length} workers
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-gradient-to-r from-blue-50 to-indigo-50 p-2 rounded-xl border-2 border-blue-200">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setViewMode("cards")}
|
||||
className={viewMode === "cards" ? "bg-white text-slate-900 shadow-sm hover:bg-white" : "text-slate-600 hover:bg-white/50"}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4 mr-2" />
|
||||
Grid
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setViewMode("table")}
|
||||
className={viewMode === "table" ? "bg-[#0A39DF] text-white hover:bg-blue-700 shadow-lg" : "text-slate-600 hover:bg-white/50"}
|
||||
>
|
||||
<List className="w-4 h-4 mr-2" />
|
||||
List
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="border border-slate-200 bg-slate-50/50 hover:shadow-md transition-all">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Needs Work</p>
|
||||
<p className="text-3xl font-bold text-slate-900 mb-0.5">{metrics.needsWork}</p>
|
||||
<p className="text-slate-500 text-xs">Available workers</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-white border border-slate-200 shadow-sm rounded-xl flex items-center justify-center">
|
||||
<TrendingDown className="w-6 h-6 text-slate-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-green-200 bg-green-50 hover:shadow-md transition-all">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Fully Booked</p>
|
||||
<p className="text-3xl font-bold text-slate-900 mb-0.5">{metrics.fullyBooked}</p>
|
||||
<p className="text-slate-500 text-xs">At capacity</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-white border border-green-200 shadow-sm rounded-xl flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-teal-200 bg-teal-50 hover:shadow-md transition-all">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Active</p>
|
||||
<p className="text-3xl font-bold text-slate-900 mb-0.5">{metrics.hasUtilization}</p>
|
||||
<p className="text-slate-500 text-xs">Working now</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-white border border-teal-200 shadow-sm rounded-xl flex items-center justify-center">
|
||||
<TrendingUp className="w-6 h-6 text-teal-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-blue-200 bg-blue-50 hover:shadow-md transition-all">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">On Time Off</p>
|
||||
<p className="text-3xl font-bold text-slate-900 mb-0.5">{metrics.onTimeOff}</p>
|
||||
<p className="text-slate-500 text-xs">Unavailable</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-white border border-blue-200 shadow-sm rounded-xl flex items-center justify-center">
|
||||
<XCircle className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card className="border border-slate-200 shadow-sm bg-white">
|
||||
<CardContent className="p-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div className="md:col-span-2 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search by name..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 h-10 border border-slate-300 focus:border-[#0A39DF]"
|
||||
/>
|
||||
</div>
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
<SelectTrigger className="h-10 border border-slate-300">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="CONFIRMED_AVAILABLE">Available</SelectItem>
|
||||
<SelectItem value="UNKNOWN">Unknown</SelectItem>
|
||||
<SelectItem value="BLOCKED">Blocked</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterUtilization} onValueChange={setFilterUtilization}>
|
||||
<SelectTrigger className="h-10 border border-slate-300">
|
||||
<SelectValue placeholder="Utilization" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Utilization</SelectItem>
|
||||
<SelectItem value="underutilized">< 50%</SelectItem>
|
||||
<SelectItem value="optimal">50-99%</SelectItem>
|
||||
<SelectItem value="full">100%+</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="h-10 border border-slate-300">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="need_work_index">Hours Gap</SelectItem>
|
||||
<SelectItem value="utilization">Utilization</SelectItem>
|
||||
<SelectItem value="availability_score">Availability Score</SelectItem>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Main Content - Table or Cards View */}
|
||||
{viewMode === "table" ? (
|
||||
<Card className="border border-slate-200 shadow-sm bg-white">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gradient-to-r from-slate-50 to-blue-50 border-b border-slate-200 hover:bg-gradient-to-r">
|
||||
<TableHead className="font-bold text-slate-700 uppercase text-xs">Name</TableHead>
|
||||
<TableHead className="font-bold text-slate-700 uppercase text-xs">Status</TableHead>
|
||||
<TableHead className="text-center font-bold text-slate-700 uppercase text-xs">Hours</TableHead>
|
||||
<TableHead className="text-center font-bold text-slate-700 uppercase text-xs">Utilization</TableHead>
|
||||
<TableHead className="text-center font-bold text-slate-700 uppercase text-xs">Hours Gap</TableHead>
|
||||
<TableHead className="text-center font-bold text-slate-700 uppercase text-xs">Acceptance</TableHead>
|
||||
<TableHead className="text-center font-bold text-slate-700 uppercase text-xs">Last Shift</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-slate-500">
|
||||
No workers found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedData.map((worker) => (
|
||||
<TableRow key={worker.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarFallback className="bg-blue-100 text-blue-700 text-xs font-semibold">
|
||||
{worker.staff_name?.charAt(0) || "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium">{worker.staff_name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-4">
|
||||
{getStatusBadge(worker)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-4">
|
||||
<span className="font-bold text-slate-900">
|
||||
{worker.scheduled_hours_this_period}h
|
||||
</span>
|
||||
<span className="text-slate-500"> / {worker.desired_hours_this_period}h</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className={`font-bold text-lg ${getUtilizationColor(worker.utilization_percentage)}`}>
|
||||
{Math.round(worker.utilization_percentage)}%
|
||||
</span>
|
||||
<div className="w-full max-w-[120px] h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${
|
||||
worker.utilization_percentage < 50 ? 'bg-red-500' :
|
||||
worker.utilization_percentage < 80 ? 'bg-amber-500' :
|
||||
'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, worker.utilization_percentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-4">
|
||||
{worker.scheduled_hours_this_period < worker.desired_hours_this_period ? (
|
||||
<Badge className="bg-red-100 text-red-800 font-bold border border-red-200">
|
||||
Needs {worker.desired_hours_this_period - worker.scheduled_hours_this_period}h
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-green-100 text-green-800 font-bold border border-green-200">
|
||||
Fully booked
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-4">
|
||||
<span className="font-bold text-slate-900">{worker.acceptance_rate || 0}%</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm text-slate-700 font-medium py-4">
|
||||
{worker.last_shift_date ? format(new Date(worker.last_shift_date), 'MMM d') : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{paginatedData.map((worker) => {
|
||||
const staff = allStaff.find(s => s.id === worker.staff_id);
|
||||
|
||||
return (
|
||||
<Card key={worker.id} className="bg-white border border-slate-200 hover:border-blue-300 hover:shadow-lg transition-all">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="w-14 h-14 bg-blue-100 shadow-md ring-2 ring-blue-200">
|
||||
<AvatarFallback className="bg-blue-100 text-blue-700 font-bold text-lg">
|
||||
{worker.staff_name?.charAt(0)?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-slate-900 truncate text-base">{worker.staff_name}</p>
|
||||
<p className="text-xs text-slate-600 font-medium">{staff?.position || 'Staff'}</p>
|
||||
|
||||
<div className="flex gap-1.5 mt-3 flex-wrap">
|
||||
{getStatusBadge(worker)}
|
||||
{worker.scheduled_hours_this_period < worker.desired_hours_this_period && (
|
||||
<Badge className="bg-red-100 text-red-800 font-bold border border-red-200">
|
||||
Needs {worker.desired_hours_this_period - worker.scheduled_hours_this_period}h
|
||||
</Badge>
|
||||
)}
|
||||
{worker.scheduled_hours_this_period >= worker.desired_hours_this_period && (
|
||||
<Badge className="bg-green-100 text-green-800 font-bold border border-green-200">
|
||||
Fully booked
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="bg-slate-50/50 border border-slate-200 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold text-slate-700 uppercase tracking-wide">Weekly Hours</span>
|
||||
<span className="text-sm font-bold text-slate-900">
|
||||
{worker.scheduled_hours_this_period}h / {worker.desired_hours_this_period}h
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
worker.utilization_percentage < 50 ? 'bg-red-500' :
|
||||
worker.utilization_percentage < 80 ? 'bg-amber-500' :
|
||||
'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, worker.utilization_percentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{worker.last_shift_date && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-600">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span>Last shift: {format(new Date(worker.last_shift_date), 'MMM d')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
<Card className="border border-slate-200 shadow-sm bg-white">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="text-sm text-slate-700 font-medium">
|
||||
Showing {((currentPage - 1) * itemsPerPage) + 1} to {Math.min(currentPage * itemsPerPage, filteredAvailability.length)} of {filteredAvailability.length} workers
|
||||
</p>
|
||||
<Select value={itemsPerPage.toString()} onValueChange={(val) => { setItemsPerPage(parseInt(val)); setCurrentPage(1); }}>
|
||||
<SelectTrigger className="w-24 h-9 border border-slate-300">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="25">25</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="250">250</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="border-slate-300 hover:bg-blue-50 disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-bold px-4 text-slate-900">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="border-slate-300 hover:bg-blue-50 disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
307
frontend-web-free/src/pages/StaffDirectory.jsx
Normal file
307
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
197
frontend-web-free/src/pages/StaffOnboarding.jsx
Normal file
197
frontend-web-free/src/pages/StaffOnboarding.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckCircle, Circle } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import ProfileSetupStep from "../components/onboarding/ProfileSetupStep";
|
||||
import DocumentUploadStep from "../components/onboarding/DocumentUploadStep";
|
||||
import TrainingStep from "../components/onboarding/TrainingStep";
|
||||
import CompletionStep from "../components/onboarding/CompletionStep";
|
||||
|
||||
const steps = [
|
||||
{ id: 1, name: "Profile Setup", description: "Basic information" },
|
||||
{ id: 2, name: "Documents", description: "Upload required documents" },
|
||||
{ id: 3, name: "Training", description: "Complete compliance training" },
|
||||
{ id: 4, name: "Complete", description: "Finish onboarding" },
|
||||
];
|
||||
|
||||
export default function StaffOnboarding() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [onboardingData, setOnboardingData] = useState({
|
||||
profile: {},
|
||||
documents: [],
|
||||
training: { completed: [] },
|
||||
});
|
||||
|
||||
const { data: currentUser } = useQuery({
|
||||
queryKey: ['current-user-onboarding'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const createStaffMutation = useMutation({
|
||||
mutationFn: (staffData) => base44.entities.Staff.create(staffData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
||||
toast({
|
||||
title: "✅ Onboarding Complete",
|
||||
description: "Welcome to KROW! Your profile is now active.",
|
||||
});
|
||||
navigate(createPageUrl("WorkforceDashboard"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "❌ Onboarding Failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleNext = (stepData) => {
|
||||
setOnboardingData(prev => ({
|
||||
...prev,
|
||||
[stepData.type]: stepData.data,
|
||||
}));
|
||||
|
||||
if (currentStep < steps.length) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
const staffData = {
|
||||
employee_name: onboardingData.profile.full_name,
|
||||
email: onboardingData.profile.email || currentUser?.email,
|
||||
phone: onboardingData.profile.phone,
|
||||
address: onboardingData.profile.address,
|
||||
city: onboardingData.profile.city,
|
||||
position: onboardingData.profile.position,
|
||||
department: onboardingData.profile.department,
|
||||
hub_location: onboardingData.profile.hub_location,
|
||||
employment_type: onboardingData.profile.employment_type,
|
||||
english: onboardingData.profile.english_level,
|
||||
certifications: onboardingData.documents.filter(d => d.type === 'certification').map(d => ({
|
||||
name: d.name,
|
||||
issued_date: d.issued_date,
|
||||
expiry_date: d.expiry_date,
|
||||
document_url: d.url,
|
||||
})),
|
||||
background_check_status: onboardingData.documents.some(d => d.type === 'background_check') ? 'pending' : 'not_required',
|
||||
notes: `Onboarding completed: ${new Date().toISOString()}. Training modules completed: ${onboardingData.training.completed.length}`,
|
||||
};
|
||||
|
||||
createStaffMutation.mutate(staffData);
|
||||
};
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<ProfileSetupStep
|
||||
data={onboardingData.profile}
|
||||
onNext={handleNext}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<DocumentUploadStep
|
||||
data={onboardingData.documents}
|
||||
onNext={handleNext}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<TrainingStep
|
||||
data={onboardingData.training}
|
||||
onNext={handleNext}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<CompletionStep
|
||||
data={onboardingData}
|
||||
onComplete={handleComplete}
|
||||
onBack={handleBack}
|
||||
isSubmitting={createStaffMutation.isPending}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-slate-900 mb-2">
|
||||
Welcome to KROW! 👋
|
||||
</h1>
|
||||
<p className="text-slate-600">
|
||||
Let's get you set up in just a few steps
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, idx) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
|
||||
currentStep > step.id
|
||||
? "bg-green-500 text-white"
|
||||
: currentStep === step.id
|
||||
? "bg-[#0A39DF] text-white"
|
||||
: "bg-slate-200 text-slate-400"
|
||||
}`}>
|
||||
{currentStep > step.id ? (
|
||||
<CheckCircle className="w-6 h-6" />
|
||||
) : (
|
||||
<span className="font-bold">{step.id}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-sm font-medium mt-2 ${
|
||||
currentStep >= step.id ? "text-slate-900" : "text-slate-400"
|
||||
}`}>
|
||||
{step.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">{step.description}</p>
|
||||
</div>
|
||||
{idx < steps.length - 1 && (
|
||||
<div className={`flex-1 h-1 ${
|
||||
currentStep > step.id ? "bg-green-500" : "bg-slate-200"
|
||||
}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<Card>
|
||||
<CardContent className="p-6 md:p-8">
|
||||
{renderStep()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend-web-free/src/pages/Support.jsx
Normal file
19
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
650
frontend-web-free/src/pages/TaskBoard.jsx
Normal file
650
frontend-web-free/src/pages/TaskBoard.jsx
Normal file
@@ -0,0 +1,650 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { DragDropContext, Draggable } from "@hello-pangea/dnd";
|
||||
import { Link2, Plus, Users, Search, UserCircle, Filter, ArrowUpDown, EyeOff, Grid3x3, MoreVertical, Pin, Ruler, Palette } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import TaskCard from "../components/tasks/TaskCard";
|
||||
import TaskColumn from "../components/tasks/TaskColumn";
|
||||
import TaskDetailModal from "../components/tasks/TaskDetailModal";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
export default function TaskBoard() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [createDialog, setCreateDialog] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
const [selectedStatus, setSelectedStatus] = useState("pending");
|
||||
const [newTask, setNewTask] = useState({
|
||||
task_name: "",
|
||||
description: "",
|
||||
priority: "normal",
|
||||
due_date: "",
|
||||
progress: 0,
|
||||
assigned_members: []
|
||||
});
|
||||
const [selectedMembers, setSelectedMembers] = useState([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterPerson, setFilterPerson] = useState("all");
|
||||
const [filterPriority, setFilterPriority] = useState("all");
|
||||
const [sortBy, setSortBy] = useState("due_date");
|
||||
const [showCompleted, setShowCompleted] = useState(true);
|
||||
const [groupBy, setGroupBy] = useState("status");
|
||||
const [pinnedColumns, setPinnedColumns] = useState([]);
|
||||
const [itemHeight, setItemHeight] = useState("normal");
|
||||
const [conditionalColoring, setConditionalColoring] = useState(true);
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-taskboard'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: teams = [] } = useQuery({
|
||||
queryKey: ['teams'],
|
||||
queryFn: () => base44.entities.Team.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: teamMembers = [] } = useQuery({
|
||||
queryKey: ['team-members'],
|
||||
queryFn: () => base44.entities.TeamMember.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: tasks = [] } = useQuery({
|
||||
queryKey: ['tasks'],
|
||||
queryFn: () => base44.entities.Task.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const userTeam = teams.find(t => t.owner_id === user?.id) || teams[0];
|
||||
let teamTasks = tasks.filter(t => t.team_id === userTeam?.id);
|
||||
|
||||
// Apply filters
|
||||
if (searchQuery) {
|
||||
teamTasks = teamTasks.filter(t =>
|
||||
t.task_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (filterPerson !== "all") {
|
||||
teamTasks = teamTasks.filter(t =>
|
||||
t.assigned_members?.some(m => m.member_id === filterPerson)
|
||||
);
|
||||
}
|
||||
|
||||
if (filterPriority !== "all") {
|
||||
teamTasks = teamTasks.filter(t => t.priority === filterPriority);
|
||||
}
|
||||
|
||||
if (!showCompleted) {
|
||||
teamTasks = teamTasks.filter(t => t.status !== "completed");
|
||||
}
|
||||
|
||||
const currentTeamMembers = teamMembers.filter(m => m.team_id === userTeam?.id);
|
||||
|
||||
const leadMembers = currentTeamMembers.filter(m => m.role === 'admin' || m.role === 'manager');
|
||||
const regularMembers = currentTeamMembers.filter(m => m.role === 'member');
|
||||
|
||||
// Get unique departments from team members
|
||||
const departments = [...new Set(currentTeamMembers.map(m => m.department).filter(Boolean))];
|
||||
|
||||
const sortTasks = (tasks) => {
|
||||
return [...tasks].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case "due_date":
|
||||
return new Date(a.due_date || '9999-12-31') - new Date(b.due_date || '9999-12-31');
|
||||
case "priority":
|
||||
const priorityOrder = { high: 0, normal: 1, low: 2 };
|
||||
return (priorityOrder[a.priority] || 1) - (priorityOrder[b.priority] || 1);
|
||||
case "created_date":
|
||||
return new Date(b.created_date || 0) - new Date(a.created_date || 0);
|
||||
case "task_name":
|
||||
return (a.task_name || '').localeCompare(b.task_name || '');
|
||||
default:
|
||||
return (a.order_index || 0) - (b.order_index || 0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const tasksByStatus = useMemo(() => ({
|
||||
pending: sortTasks(teamTasks.filter(t => t.status === 'pending')),
|
||||
in_progress: sortTasks(teamTasks.filter(t => t.status === 'in_progress')),
|
||||
on_hold: sortTasks(teamTasks.filter(t => t.status === 'on_hold')),
|
||||
completed: sortTasks(teamTasks.filter(t => t.status === 'completed')),
|
||||
}), [teamTasks, sortBy]);
|
||||
|
||||
const overallProgress = useMemo(() => {
|
||||
if (teamTasks.length === 0) return 0;
|
||||
const totalProgress = teamTasks.reduce((sum, t) => sum + (t.progress || 0), 0);
|
||||
return Math.round(totalProgress / teamTasks.length);
|
||||
}, [teamTasks]);
|
||||
|
||||
const createTaskMutation = useMutation({
|
||||
mutationFn: (taskData) => base44.entities.Task.create(taskData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
setCreateDialog(false);
|
||||
setNewTask({
|
||||
task_name: "",
|
||||
description: "",
|
||||
priority: "normal",
|
||||
due_date: "",
|
||||
progress: 0,
|
||||
assigned_members: []
|
||||
});
|
||||
setSelectedMembers([]);
|
||||
toast({
|
||||
title: "✅ Task Created",
|
||||
description: "New task added to the board",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateTaskMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Task.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDragEnd = (result) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const { source, destination, draggableId } = result;
|
||||
|
||||
if (source.droppableId === destination.droppableId && source.index === destination.index) {
|
||||
return;
|
||||
}
|
||||
|
||||
const task = teamTasks.find(t => t.id === draggableId);
|
||||
if (!task) return;
|
||||
|
||||
const newStatus = destination.droppableId;
|
||||
updateTaskMutation.mutate({
|
||||
id: task.id,
|
||||
data: {
|
||||
...task,
|
||||
status: newStatus,
|
||||
order_index: destination.index
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateTask = () => {
|
||||
if (!newTask.task_name.trim()) {
|
||||
toast({
|
||||
title: "Task name required",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createTaskMutation.mutate({
|
||||
...newTask,
|
||||
team_id: userTeam?.id,
|
||||
status: selectedStatus,
|
||||
order_index: tasksByStatus[selectedStatus]?.length || 0,
|
||||
assigned_members: selectedMembers.map(m => ({
|
||||
member_id: m.id,
|
||||
member_name: m.member_name,
|
||||
avatar_url: m.avatar_url
|
||||
})),
|
||||
assigned_department: selectedMembers.length > 0 && selectedMembers[0].department ? selectedMembers[0].department : null
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-[1800px] mx-auto">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl p-6 mb-6 shadow-sm border border-slate-200">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-slate-200">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<UserCircle className="w-4 h-4" />
|
||||
Person
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuItem onClick={() => setFilterPerson("all")}>
|
||||
All People
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{currentTeamMembers.map((member) => (
|
||||
<DropdownMenuItem
|
||||
key={member.id}
|
||||
onClick={() => setFilterPerson(member.id)}
|
||||
>
|
||||
{member.member_name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
Filter
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuLabel>Priority</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setFilterPriority("all")}>All</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setFilterPriority("high")}>High</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setFilterPriority("normal")}>Normal</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setFilterPriority("low")}>Low</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<ArrowUpDown className="w-4 h-4" />
|
||||
Sort
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => setSortBy("due_date")}>Due Date</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy("priority")}>Priority</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy("created_date")}>Created Date</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy("task_name")}>Name</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => setShowCompleted(!showCompleted)}
|
||||
>
|
||||
<EyeOff className="w-4 h-4" />
|
||||
Hide
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Grid3x3 className="w-4 h-4" />
|
||||
Group by
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => setGroupBy("status")}>Status</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setGroupBy("priority")}>Priority</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setGroupBy("assigned")}>Assigned To</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={() => setPinnedColumns(pinnedColumns.length > 0 ? [] : ['pending'])}>
|
||||
<Pin className="w-4 h-4 mr-2" />
|
||||
Pin columns
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Item height</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setItemHeight("compact")}>
|
||||
<Ruler className="w-4 h-4 mr-2" />
|
||||
Compact
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setItemHeight("normal")}>
|
||||
<Ruler className="w-4 h-4 mr-2" />
|
||||
Normal
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setItemHeight("comfortable")}>
|
||||
<Ruler className="w-4 h-4 mr-2" />
|
||||
Comfortable
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setConditionalColoring(!conditionalColoring)}>
|
||||
<Palette className="w-4 h-4 mr-2" />
|
||||
Conditional coloring
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-2">Task Board</h1>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-600">Lead</span>
|
||||
<div className="flex -space-x-2">
|
||||
{leadMembers.slice(0, 3).map((member, idx) => (
|
||||
<Avatar key={idx} className="w-8 h-8 border-2 border-white">
|
||||
<img
|
||||
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
|
||||
alt={member.member_name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</Avatar>
|
||||
))}
|
||||
{leadMembers.length > 3 && (
|
||||
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-semibold text-slate-600">
|
||||
+{leadMembers.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-600">Team</span>
|
||||
<div className="flex -space-x-2">
|
||||
{regularMembers.slice(0, 3).map((member, idx) => (
|
||||
<Avatar key={idx} className="w-8 h-8 border-2 border-white">
|
||||
<img
|
||||
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
|
||||
alt={member.member_name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</Avatar>
|
||||
))}
|
||||
{regularMembers.length > 3 && (
|
||||
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-semibold text-slate-600">
|
||||
+{regularMembers.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" className="gap-2 bg-white hover:bg-slate-50 border border-slate-300 text-slate-700 font-medium">
|
||||
<Link2 className="w-4 h-4" />
|
||||
Share
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedStatus("pending");
|
||||
setCreateDialog(true);
|
||||
}}
|
||||
className="gap-2 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold shadow-md"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overall Progress */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 h-3 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[#0A39DF] to-blue-600 transition-all"
|
||||
style={{ width: `${overallProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-slate-900 ml-4">{overallProgress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kanban Board */}
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{['pending', 'in_progress', 'on_hold', 'completed'].map((status) => (
|
||||
<TaskColumn
|
||||
key={status}
|
||||
status={status}
|
||||
tasks={tasksByStatus[status]}
|
||||
onAddTask={(status) => {
|
||||
setSelectedStatus(status);
|
||||
setCreateDialog(true);
|
||||
}}
|
||||
>
|
||||
{tasksByStatus[status].map((task, index) => (
|
||||
<Draggable key={task.id} draggableId={task.id} index={index}>
|
||||
{(provided) => (
|
||||
<TaskCard
|
||||
task={task}
|
||||
provided={provided}
|
||||
onClick={() => setSelectedTask(task)}
|
||||
itemHeight={itemHeight}
|
||||
conditionalColoring={conditionalColoring}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
</TaskColumn>
|
||||
))}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
|
||||
{teamTasks.length === 0 && (
|
||||
<div className="text-center py-16 bg-white rounded-xl border-2 border-dashed border-slate-300">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-slate-100 rounded-xl flex items-center justify-center">
|
||||
<Plus className="w-8 h-8 text-slate-400" />
|
||||
</div>
|
||||
<h3 className="font-bold text-xl text-slate-900 mb-2">No tasks yet</h3>
|
||||
<p className="text-slate-600 mb-5">Create your first task to get started</p>
|
||||
<Button onClick={() => setCreateDialog(true)} className="bg-[#0A39DF]">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Task
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Task Dialog */}
|
||||
<Dialog open={createDialog} onOpenChange={setCreateDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label>Task Name *</Label>
|
||||
<Input
|
||||
value={newTask.task_name}
|
||||
onChange={(e) => setNewTask({ ...newTask, task_name: e.target.value })}
|
||||
placeholder="e.g., Website Design"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={newTask.description}
|
||||
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
||||
placeholder="Task details..."
|
||||
rows={3}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Priority</Label>
|
||||
<Select value={newTask.priority} onValueChange={(val) => setNewTask({ ...newTask, priority: val })}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="normal">Normal</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Due Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={newTask.due_date}
|
||||
onChange={(e) => setNewTask({ ...newTask, due_date: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Initial Progress (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={newTask.progress}
|
||||
onChange={(e) => setNewTask({ ...newTask, progress: parseInt(e.target.value) || 0 })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>Assign Team Members</Label>
|
||||
{departments.length > 0 && (
|
||||
<Select onValueChange={(dept) => {
|
||||
const deptMembers = currentTeamMembers.filter(m => m.department === dept);
|
||||
setSelectedMembers(deptMembers);
|
||||
}}>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="Assign entire department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{departments.map((dept) => {
|
||||
const count = currentTeamMembers.filter(m => m.department === dept).length;
|
||||
return (
|
||||
<SelectItem key={dept} value={dept}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
{dept} ({count} members)
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{currentTeamMembers.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No team members available</p>
|
||||
) : (
|
||||
<div className="max-h-48 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
|
||||
{currentTeamMembers.map((member) => {
|
||||
const isSelected = selectedMembers.some(m => m.id === member.id);
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
setSelectedMembers(selectedMembers.filter(m => m.id !== member.id));
|
||||
} else {
|
||||
setSelectedMembers([...selectedMembers, member]);
|
||||
}
|
||||
}}
|
||||
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-all ${
|
||||
isSelected ? 'bg-blue-50 border-2 border-[#0A39DF]' : 'hover:bg-slate-50 border-2 border-transparent'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
className="w-4 h-4 rounded text-[#0A39DF] focus:ring-[#0A39DF]"
|
||||
/>
|
||||
<Avatar className="w-8 h-8">
|
||||
<img
|
||||
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
|
||||
alt={member.member_name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-900">{member.member_name}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{member.department ? `${member.department} • ` : ''}{member.role || 'Member'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selectedMembers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-slate-50 rounded-lg">
|
||||
{selectedMembers.map((member) => (
|
||||
<Badge key={member.id} className="bg-[#0A39DF] text-white flex items-center gap-1">
|
||||
{member.member_name}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedMembers(selectedMembers.filter(m => m.id !== member.id));
|
||||
}}
|
||||
className="ml-1 hover:bg-white/20 rounded-full p-0.5"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateTask}
|
||||
disabled={createTaskMutation.isPending}
|
||||
className="bg-[#0A39DF]"
|
||||
>
|
||||
Create Task
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Task Detail Modal with Comments */}
|
||||
<TaskDetailModal
|
||||
task={selectedTask}
|
||||
open={!!selectedTask}
|
||||
onClose={() => setSelectedTask(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1249
frontend-web-free/src/pages/TeamDetails.jsx
Normal file
1249
frontend-web-free/src/pages/TeamDetails.jsx
Normal file
File diff suppressed because it is too large
Load Diff
2482
frontend-web-free/src/pages/Teams.jsx
Normal file
2482
frontend-web-free/src/pages/Teams.jsx
Normal file
File diff suppressed because it is too large
Load Diff
469
frontend-web-free/src/pages/Tutorials.jsx
Normal file
469
frontend-web-free/src/pages/Tutorials.jsx
Normal file
@@ -0,0 +1,469 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Play, Search, Calendar, Users, FileText, UserPlus, Building2,
|
||||
DollarSign, MessageSquare, Award, TrendingUp, MapPin,
|
||||
Briefcase, Package, CheckSquare, Headphones, Mail
|
||||
} from "lucide-react";
|
||||
import PageHeader from "../components/common/PageHeader";
|
||||
|
||||
export default function Tutorials() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||
const [playingVideo, setPlayingVideo] = useState(null);
|
||||
|
||||
const tutorials = [
|
||||
{
|
||||
id: 1,
|
||||
title: "How to Create an Event Order",
|
||||
description: "Learn how to create a new event booking with staff requirements, shifts, and scheduling",
|
||||
category: "Events",
|
||||
duration: "3:45",
|
||||
icon: Calendar,
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
steps: [
|
||||
"Navigate to Events page",
|
||||
"Click 'Create Event' button",
|
||||
"Fill in event details (name, date, location)",
|
||||
"Add shift requirements and roles",
|
||||
"Set headcount for each position",
|
||||
"Review and submit"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Inviting Team Members",
|
||||
description: "Step-by-step guide to invite new members to your team and assign them to hubs",
|
||||
category: "Team Management",
|
||||
duration: "2:30",
|
||||
icon: UserPlus,
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
steps: [
|
||||
"Go to Teams page",
|
||||
"Click 'Invite Member' button",
|
||||
"Enter member's name and email",
|
||||
"Select role and department",
|
||||
"Choose hub location",
|
||||
"Send invitation email"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Creating and Managing Hubs",
|
||||
description: "How to create location hubs and organize departments within them",
|
||||
category: "Team Management",
|
||||
duration: "4:15",
|
||||
icon: MapPin,
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
steps: [
|
||||
"Navigate to Teams → Hubs tab",
|
||||
"Click 'Create Hub' button",
|
||||
"Enter hub name (e.g., BVG300)",
|
||||
"Add location address",
|
||||
"Assign hub manager",
|
||||
"Add departments with cost centers"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Staff Assignment & Scheduling",
|
||||
description: "Assign staff to events, manage schedules, and handle conflicts",
|
||||
category: "Staff Management",
|
||||
duration: "5:20",
|
||||
icon: Users,
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
steps: [
|
||||
"Open event details",
|
||||
"Click 'Assign Staff' button",
|
||||
"Filter staff by role and rating",
|
||||
"Select staff members",
|
||||
"Review conflict warnings",
|
||||
"Confirm assignments"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Creating and Sending Invoices",
|
||||
description: "Generate invoices from events and send them to clients",
|
||||
category: "Invoicing",
|
||||
duration: "3:50",
|
||||
icon: FileText,
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
steps: [
|
||||
"Go to Invoices page",
|
||||
"Click 'Create Invoice'",
|
||||
"Select event or create manually",
|
||||
"Review line items and totals",
|
||||
"Set payment terms",
|
||||
"Send to client via email"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Vendor Onboarding Process",
|
||||
description: "Complete guide to onboarding new vendors with compliance documents",
|
||||
category: "Vendor Management",
|
||||
duration: "6:10",
|
||||
icon: Package,
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
steps: [
|
||||
"Navigate to Vendors",
|
||||
"Click 'Add Vendor'",
|
||||
"Enter vendor details and contacts",
|
||||
"Upload W9 and COI documents",
|
||||
"Set coverage regions and roles",
|
||||
"Submit for approval"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "Managing Vendor Rates",
|
||||
description: "Set up and manage vendor pricing, markups, and client rates",
|
||||
category: "Vendor Management",
|
||||
duration: "4:30",
|
||||
icon: DollarSign,
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
steps: [
|
||||
"Go to Vendor Rates page",
|
||||
"Click 'Add New Rate'",
|
||||
"Select category and role",
|
||||
"Enter employee wage",
|
||||
"Set markup and vendor fee %",
|
||||
"Review client rate and save"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: "Staff Onboarding Tutorial",
|
||||
description: "Onboard new staff members with all required information and documents",
|
||||
category: "Staff Management",
|
||||
duration: "5:00",
|
||||
icon: Users,
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
steps: [
|
||||
"Navigate to Onboarding page",
|
||||
"Enter staff personal details",
|
||||
"Add employment information",
|
||||
"Upload certifications",
|
||||
"Set availability and skills",
|
||||
"Complete profile setup"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: "Using the Messaging System",
|
||||
description: "Communicate with team members, vendors, and clients through built-in messaging",
|
||||
category: "Communication",
|
||||
duration: "2:45",
|
||||
icon: MessageSquare,
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
steps: [
|
||||
"Go to Messages page",
|
||||
"Start new conversation",
|
||||
"Select participants",
|
||||
"Type and send messages",
|
||||
"Attach files if needed",
|
||||
"Archive old conversations"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
title: "Managing Certifications",
|
||||
description: "Track and manage employee certifications and compliance documents",
|
||||
category: "Compliance",
|
||||
duration: "3:20",
|
||||
icon: Award,
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
steps: [
|
||||
"Navigate to Certifications",
|
||||
"Click 'Add Certification'",
|
||||
"Select employee and cert type",
|
||||
"Enter issue and expiry dates",
|
||||
"Upload certificate document",
|
||||
"Submit for validation"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
title: "Enterprise & Sector Setup",
|
||||
description: "Set up enterprise organizations and manage multiple sectors",
|
||||
category: "Enterprise",
|
||||
duration: "5:40",
|
||||
icon: Building2,
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
steps: [
|
||||
"Go to Enterprise Management",
|
||||
"Click 'Add Enterprise'",
|
||||
"Enter enterprise details",
|
||||
"Add brand family members",
|
||||
"Create sectors under enterprise",
|
||||
"Link partners to sectors"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
title: "Partner & Client Management",
|
||||
description: "Add partners, manage sites, and configure client relationships",
|
||||
category: "Partners",
|
||||
duration: "4:00",
|
||||
icon: Briefcase,
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
steps: [
|
||||
"Navigate to Partners",
|
||||
"Click 'Add Partner'",
|
||||
"Enter partner information",
|
||||
"Add multiple sites",
|
||||
"Configure allowed vendors",
|
||||
"Set payment terms"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
title: "Generating Reports & Analytics",
|
||||
description: "Create custom reports and analyze workforce performance data",
|
||||
category: "Reports",
|
||||
duration: "4:25",
|
||||
icon: TrendingUp,
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
steps: [
|
||||
"Go to Reports page",
|
||||
"Select report type",
|
||||
"Choose date range",
|
||||
"Apply filters (vendor, client, etc.)",
|
||||
"Generate report",
|
||||
"Export to PDF or Excel"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
title: "Task Board & Project Management",
|
||||
description: "Use the task board to track work items and collaborate with your team",
|
||||
category: "Productivity",
|
||||
duration: "3:10",
|
||||
icon: CheckSquare,
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
steps: [
|
||||
"Navigate to Task Board",
|
||||
"Create new task",
|
||||
"Assign to team members",
|
||||
"Set due dates and priority",
|
||||
"Move tasks between columns",
|
||||
"Mark tasks as complete"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
title: "Role-Based Permissions",
|
||||
description: "Configure user roles and permissions across the platform",
|
||||
category: "Administration",
|
||||
duration: "3:55",
|
||||
icon: Users,
|
||||
videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
steps: [
|
||||
"Go to Permissions page",
|
||||
"Select user role",
|
||||
"Configure access levels",
|
||||
"Set entity permissions",
|
||||
"Enable/disable features",
|
||||
"Save permission settings"
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const categories = ["all", ...new Set(tutorials.map(t => t.category))];
|
||||
|
||||
const filteredTutorials = tutorials.filter(tutorial => {
|
||||
const matchesSearch = !searchTerm ||
|
||||
tutorial.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
tutorial.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesCategory = selectedCategory === "all" || tutorial.category === selectedCategory;
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-4 md:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<PageHeader
|
||||
title="📚 Tutorial Library"
|
||||
subtitle="Learn how to use KROW Workforce with step-by-step video guides"
|
||||
/>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="mb-8 flex flex-col md:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search tutorials..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 h-12 border-slate-300 focus:border-[#0A39DF]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{categories.map(category => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={selectedCategory === category ? "default" : "outline"}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={selectedCategory === category ? "bg-[#0A39DF]" : ""}
|
||||
>
|
||||
{category === "all" ? "All Tutorials" : category}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tutorials Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
|
||||
{filteredTutorials.map((tutorial) => (
|
||||
<Card key={tutorial.id} className="border-2 border-slate-200 hover:border-[#0A39DF] hover:shadow-2xl transition-all duration-300 overflow-hidden group">
|
||||
<CardHeader className="bg-gradient-to-r from-slate-50 to-blue-50 border-b border-slate-100">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center text-white shadow-lg">
|
||||
<tutorial.icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg text-[#1C323E] group-hover:text-[#0A39DF] transition-colors">
|
||||
{tutorial.title}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-slate-600 mt-1">{tutorial.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="bg-blue-100 text-blue-700 border-blue-200">
|
||||
{tutorial.duration}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
{/* Video Player */}
|
||||
{playingVideo === tutorial.id ? (
|
||||
<div className="relative bg-black aspect-video">
|
||||
<iframe
|
||||
src={tutorial.videoUrl}
|
||||
className="w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setPlayingVideo(tutorial.id)}
|
||||
className="relative bg-gradient-to-br from-slate-200 to-slate-300 aspect-video flex items-center justify-center group-hover:from-blue-100 group-hover:to-indigo-200 transition-all cursor-pointer"
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-full flex items-center justify-center shadow-2xl group-hover:scale-110 transition-transform">
|
||||
<Play className="w-10 h-10 text-white ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 bg-black/70 backdrop-blur-sm text-white px-4 py-2 rounded-lg text-sm font-semibold">
|
||||
Watch Tutorial
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steps */}
|
||||
<div className="p-6 bg-white">
|
||||
<h4 className="font-bold text-slate-700 mb-3 text-sm uppercase tracking-wide">What You'll Learn:</h4>
|
||||
<ul className="space-y-2">
|
||||
{tutorial.steps.map((step, idx) => (
|
||||
<li key={idx} className="flex items-start gap-3 text-sm text-slate-600">
|
||||
<div className="w-6 h-6 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<span>{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* No Results */}
|
||||
{filteredTutorials.length === 0 && (
|
||||
<div className="text-center py-16 bg-white rounded-xl border-2 border-dashed border-slate-200">
|
||||
<Search className="w-16 h-16 mx-auto text-slate-300 mb-4" />
|
||||
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Tutorials Found</h3>
|
||||
<p className="text-slate-500">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Support Section */}
|
||||
<Card className="border-2 border-blue-200 bg-gradient-to-br from-blue-50 to-indigo-50">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center">
|
||||
<Headphones className="w-10 h-10 text-blue-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-[#1C323E] mb-3">
|
||||
Questions about KROW Workforce?
|
||||
</h2>
|
||||
<p className="text-slate-600 mb-6 text-lg">
|
||||
Contact KROW support team for personalized help
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Button size="lg" className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] text-white shadow-lg">
|
||||
<MessageSquare className="w-5 h-5 mr-2" />
|
||||
Chat with Support
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" className="border-2 border-blue-300 hover:bg-blue-50">
|
||||
<Mail className="w-5 h-5 mr-2" />
|
||||
Email: support@krow.com
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card className="border-slate-200 hover:shadow-lg transition-all">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="w-14 h-14 mx-auto mb-4 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<FileText className="w-7 h-7 text-green-600" />
|
||||
</div>
|
||||
<h3 className="font-bold text-[#1C323E] mb-2">Documentation</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">Read the complete API docs</p>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
View Docs
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 hover:shadow-lg transition-all">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="w-14 h-14 mx-auto mb-4 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Users className="w-7 h-7 text-purple-600" />
|
||||
</div>
|
||||
<h3 className="font-bold text-[#1C323E] mb-2">Community Forum</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">Connect with other users</p>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
Join Forum
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 hover:shadow-lg transition-all">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="w-14 h-14 mx-auto mb-4 bg-amber-100 rounded-full flex items-center justify-center">
|
||||
<Award className="w-7 h-7 text-amber-600" />
|
||||
</div>
|
||||
<h3 className="font-bold text-[#1C323E] mb-2">Best Practices</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">Learn from experts</p>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
Read Guide
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
596
frontend-web-free/src/pages/UserManagement.jsx
Normal file
596
frontend-web-free/src/pages/UserManagement.jsx
Normal file
@@ -0,0 +1,596 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import {
|
||||
Users, UserPlus, Mail, Shield, Building2, Edit, Trash2, Search,
|
||||
Filter, MoreVertical, Eye, Key, UserCheck, UserX, Layers,
|
||||
Phone, Calendar, Clock, CheckCircle2, XCircle, AlertCircle
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import UserPermissionsModal from "../components/permissions/UserPermissionsModal";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
// Layer configuration
|
||||
const LAYERS = [
|
||||
{ id: "all", name: "All Users", icon: Users, color: "bg-slate-600" },
|
||||
{ id: "admin", name: "Admins", icon: Shield, color: "bg-red-600" },
|
||||
{ id: "procurement", name: "Procurement", icon: Building2, color: "bg-blue-600" },
|
||||
{ id: "operator", name: "Operators", icon: Building2, color: "bg-emerald-600" },
|
||||
{ id: "sector", name: "Sectors", icon: Layers, color: "bg-purple-600" },
|
||||
{ id: "client", name: "Clients", icon: Users, color: "bg-green-600" },
|
||||
{ id: "vendor", name: "Vendors", icon: Building2, color: "bg-amber-600" },
|
||||
{ id: "workforce", name: "Workforce", icon: Users, color: "bg-slate-500" },
|
||||
];
|
||||
|
||||
const ROLE_CONFIG = {
|
||||
admin: { name: "Administrator", color: "bg-red-100 text-red-700 border-red-200", bgGradient: "from-red-500 to-red-700" },
|
||||
procurement: { name: "Procurement", color: "bg-blue-100 text-blue-700 border-blue-200", bgGradient: "from-blue-500 to-blue-700" },
|
||||
operator: { name: "Operator", color: "bg-emerald-100 text-emerald-700 border-emerald-200", bgGradient: "from-emerald-500 to-emerald-700" },
|
||||
sector: { name: "Sector Manager", color: "bg-purple-100 text-purple-700 border-purple-200", bgGradient: "from-purple-500 to-purple-700" },
|
||||
client: { name: "Client", color: "bg-green-100 text-green-700 border-green-200", bgGradient: "from-green-500 to-green-700" },
|
||||
vendor: { name: "Vendor", color: "bg-amber-100 text-amber-700 border-amber-200", bgGradient: "from-amber-500 to-amber-700" },
|
||||
workforce: { name: "Workforce", color: "bg-slate-100 text-slate-700 border-slate-200", bgGradient: "from-slate-500 to-slate-700" },
|
||||
};
|
||||
|
||||
export default function UserManagement() {
|
||||
const [showInviteDialog, setShowInviteDialog] = useState(false);
|
||||
const [activeLayer, setActiveLayer] = useState("all");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [inviteData, setInviteData] = useState({
|
||||
email: "",
|
||||
full_name: "",
|
||||
user_role: "workforce",
|
||||
company_name: "",
|
||||
phone: "",
|
||||
department: ""
|
||||
});
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [showPermissionsModal, setShowPermissionsModal] = useState(false);
|
||||
const [showUserDetailModal, setShowUserDetailModal] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: users = [] } = useQuery({
|
||||
queryKey: ['all-users'],
|
||||
queryFn: async () => {
|
||||
const allUsers = await base44.entities.User.list('-created_date');
|
||||
return allUsers;
|
||||
},
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: currentUser } = useQuery({
|
||||
queryKey: ['current-user'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const updateUserMutation = useMutation({
|
||||
mutationFn: ({ userId, data }) => base44.entities.User.update(userId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['all-users'] });
|
||||
toast({
|
||||
title: "User Updated",
|
||||
description: "User information updated successfully",
|
||||
});
|
||||
setShowPermissionsModal(false);
|
||||
setSelectedUser(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error updating user",
|
||||
description: error.message || "Failed to update user information.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate stats per layer
|
||||
const layerStats = useMemo(() => {
|
||||
const stats = {};
|
||||
LAYERS.forEach(layer => {
|
||||
if (layer.id === "all") {
|
||||
stats[layer.id] = users.length;
|
||||
} else {
|
||||
stats[layer.id] = users.filter(u => (u.user_role || u.role) === layer.id).length;
|
||||
}
|
||||
});
|
||||
return stats;
|
||||
}, [users]);
|
||||
|
||||
// Filter users
|
||||
const filteredUsers = useMemo(() => {
|
||||
let filtered = users;
|
||||
|
||||
if (activeLayer !== "all") {
|
||||
filtered = filtered.filter(u => (u.user_role || u.role) === activeLayer);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(u =>
|
||||
u.full_name?.toLowerCase().includes(term) ||
|
||||
u.email?.toLowerCase().includes(term) ||
|
||||
u.company_name?.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [users, activeLayer, searchTerm]);
|
||||
|
||||
const handleInviteUser = async () => {
|
||||
if (!inviteData.email || !inviteData.full_name) {
|
||||
toast({
|
||||
title: "Missing Information",
|
||||
description: "Please fill in email and full name",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "User Invited",
|
||||
description: `Invitation sent to ${inviteData.email}. They will receive setup instructions via email.`,
|
||||
});
|
||||
|
||||
setShowInviteDialog(false);
|
||||
setInviteData({
|
||||
email: "",
|
||||
full_name: "",
|
||||
user_role: "workforce",
|
||||
company_name: "",
|
||||
phone: "",
|
||||
department: ""
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditPermissions = (user) => {
|
||||
setSelectedUser(user);
|
||||
setShowPermissionsModal(true);
|
||||
};
|
||||
|
||||
const handleViewUser = (user) => {
|
||||
setSelectedUser(user);
|
||||
setShowUserDetailModal(true);
|
||||
};
|
||||
|
||||
const handleSavePermissions = async (updatedUser) => {
|
||||
await updateUserMutation.mutateAsync({ userId: updatedUser.id, data: updatedUser });
|
||||
};
|
||||
|
||||
const getRoleConfig = (role) => ROLE_CONFIG[role] || ROLE_CONFIG.workforce;
|
||||
|
||||
if (currentUser?.user_role !== "admin" && currentUser?.role !== "admin") {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<Shield className="w-16 h-16 mx-auto text-red-500 mb-4" />
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-2">Access Denied</h2>
|
||||
<p className="text-slate-600">Only administrators can access user management.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sampleAvatar = "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=400&fit=crop";
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900">User Management</h1>
|
||||
<p className="text-slate-500 mt-1">Manage users across all ecosystem layers</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowInviteDialog(true)}
|
||||
className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] shadow-lg"
|
||||
>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Invite User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Layer Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-3 mb-6">
|
||||
{LAYERS.map((layer) => {
|
||||
const Icon = layer.icon;
|
||||
const isActive = activeLayer === layer.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={layer.id}
|
||||
onClick={() => setActiveLayer(layer.id)}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-center ${
|
||||
isActive
|
||||
? 'border-[#0A39DF] bg-blue-50 shadow-md scale-105'
|
||||
: 'border-slate-200 bg-white hover:border-slate-300 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-10 h-10 mx-auto rounded-lg ${layer.color} flex items-center justify-center mb-2`}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-slate-900">{layerStats[layer.id]}</p>
|
||||
<p className="text-xs text-slate-500 truncate">{layer.name}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<Card className="mb-6 border-slate-200 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search by name, email, or company..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={activeLayer} onValueChange={setActiveLayer}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
<SelectValue placeholder="Filter by layer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LAYERS.map((layer) => (
|
||||
<SelectItem key={layer.id} value={layer.id}>
|
||||
{layer.name} ({layerStats[layer.id]})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Users Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredUsers.map((user) => {
|
||||
const role = user.user_role || user.role || "workforce";
|
||||
const config = getRoleConfig(role);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={user.id}
|
||||
className="border-slate-200 hover:border-[#0A39DF] hover:shadow-lg transition-all overflow-hidden group"
|
||||
>
|
||||
{/* Role Header */}
|
||||
<div className={`h-2 bg-gradient-to-r ${config.bgGradient}`}></div>
|
||||
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<Avatar className="w-14 h-14 border-2 border-slate-200 shadow-sm">
|
||||
<AvatarImage src={user.profile_picture || sampleAvatar} alt={user.full_name} />
|
||||
<AvatarFallback className={`bg-gradient-to-br ${config.bgGradient} text-white font-bold text-lg`}>
|
||||
{user.full_name?.charAt(0) || user.email?.charAt(0) || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-900 truncate">{user.full_name || 'Unnamed User'}</h4>
|
||||
<Badge className={`${config.color} border text-xs mt-1`}>
|
||||
{config.name}
|
||||
</Badge>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleViewUser(user)}>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleEditPermissions(user)}>
|
||||
<Key className="w-4 h-4 mr-2" />
|
||||
Edit Permissions
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<UserX className="w-4 h-4 mr-2" />
|
||||
Deactivate User
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<p className="text-sm text-slate-600 flex items-center gap-2 truncate">
|
||||
<Mail className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
||||
{user.email}
|
||||
</p>
|
||||
{user.company_name && (
|
||||
<p className="text-sm text-slate-600 flex items-center gap-2 truncate">
|
||||
<Building2 className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
||||
{user.company_name}
|
||||
</p>
|
||||
)}
|
||||
{user.phone && (
|
||||
<p className="text-sm text-slate-600 flex items-center gap-2 truncate">
|
||||
<Phone className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
||||
{user.phone}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-slate-100">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => handleViewUser(user)}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs hover:bg-blue-50 hover:text-blue-700 hover:border-blue-300"
|
||||
onClick={() => handleEditPermissions(user)}
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5 mr-1" />
|
||||
Permissions
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
>
|
||||
<Edit className="w-3.5 h-3.5 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredUsers.length === 0 && (
|
||||
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
|
||||
<Users className="w-16 h-16 mx-auto text-slate-300 mb-4" />
|
||||
<h3 className="text-xl font-semibold text-slate-900 mb-2">No users found</h3>
|
||||
<p className="text-slate-600">
|
||||
{searchTerm ? "Try adjusting your search" : "No users in this layer yet"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invite User Dialog */}
|
||||
<Dialog open={showInviteDialog} onOpenChange={setShowInviteDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="w-5 h-5 text-[#0A39DF]" />
|
||||
Invite New User
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Role Selection */}
|
||||
<div>
|
||||
<Label className="text-sm font-semibold mb-3 block">Select User Role</Label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{Object.entries(ROLE_CONFIG).map(([roleId, config]) => (
|
||||
<button
|
||||
key={roleId}
|
||||
onClick={() => setInviteData({ ...inviteData, user_role: roleId })}
|
||||
className={`p-3 rounded-xl border-2 transition-all text-center ${
|
||||
inviteData.user_role === roleId
|
||||
? 'border-[#0A39DF] bg-blue-50 shadow-md'
|
||||
: 'border-slate-200 bg-white hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-8 h-8 mx-auto rounded-lg bg-gradient-to-br ${config.bgGradient} flex items-center justify-center mb-2`}>
|
||||
<Users className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<p className="text-xs font-semibold text-slate-900">{config.name}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Details */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Full Name *</Label>
|
||||
<Input
|
||||
value={inviteData.full_name}
|
||||
onChange={(e) => setInviteData({ ...inviteData, full_name: e.target.value })}
|
||||
placeholder="John Doe"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Email *</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={inviteData.email}
|
||||
onChange={(e) => setInviteData({ ...inviteData, email: e.target.value })}
|
||||
placeholder="john@example.com"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Phone</Label>
|
||||
<Input
|
||||
value={inviteData.phone}
|
||||
onChange={(e) => setInviteData({ ...inviteData, phone: e.target.value })}
|
||||
placeholder="(555) 123-4567"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Company Name</Label>
|
||||
<Input
|
||||
value={inviteData.company_name}
|
||||
onChange={(e) => setInviteData({ ...inviteData, company_name: e.target.value })}
|
||||
placeholder="Acme Corp"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Department</Label>
|
||||
<Input
|
||||
value={inviteData.department}
|
||||
onChange={(e) => setInviteData({ ...inviteData, department: e.target.value })}
|
||||
placeholder="Operations"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 rounded-xl border border-blue-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<Mail className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-900">Invitation will be sent</p>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
The user will receive an email with instructions to set up their account and access the platform as a <strong>{ROLE_CONFIG[inviteData.user_role].name}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowInviteDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleInviteUser} className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E]">
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Send Invitation
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* User Detail Modal */}
|
||||
<Dialog open={showUserDetailModal} onOpenChange={setShowUserDetailModal}>
|
||||
<DialogContent className="max-w-lg">
|
||||
{selectedUser && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>User Details</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Avatar className="w-20 h-20 border-2 border-slate-200">
|
||||
<AvatarImage src={selectedUser.profile_picture || sampleAvatar} alt={selectedUser.full_name} />
|
||||
<AvatarFallback className={`bg-gradient-to-br ${getRoleConfig(selectedUser.user_role || selectedUser.role).bgGradient} text-white font-bold text-2xl`}>
|
||||
{selectedUser.full_name?.charAt(0) || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-slate-900">{selectedUser.full_name}</h3>
|
||||
<Badge className={`${getRoleConfig(selectedUser.user_role || selectedUser.role).color} border mt-1`}>
|
||||
{getRoleConfig(selectedUser.user_role || selectedUser.role).name}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg">
|
||||
<Mail className="w-5 h-5 text-slate-400" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Email</p>
|
||||
<p className="text-sm font-medium text-slate-900">{selectedUser.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedUser.phone && (
|
||||
<div className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg">
|
||||
<Phone className="w-5 h-5 text-slate-400" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Phone</p>
|
||||
<p className="text-sm font-medium text-slate-900">{selectedUser.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedUser.company_name && (
|
||||
<div className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg">
|
||||
<Building2 className="w-5 h-5 text-slate-400" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Company</p>
|
||||
<p className="text-sm font-medium text-slate-900">{selectedUser.company_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg">
|
||||
<Calendar className="w-5 h-5 text-slate-400" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Joined</p>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{selectedUser.created_date ? new Date(selectedUser.created_date).toLocaleDateString() : 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowUserDetailModal(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowUserDetailModal(false);
|
||||
handleEditPermissions(selectedUser);
|
||||
}}
|
||||
className="bg-[#0A39DF]"
|
||||
>
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Edit Permissions
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Permissions Modal */}
|
||||
<UserPermissionsModal
|
||||
user={selectedUser}
|
||||
open={showPermissionsModal}
|
||||
onClose={() => {
|
||||
setShowPermissionsModal(false);
|
||||
setSelectedUser(null);
|
||||
}}
|
||||
onSave={handleSavePermissions}
|
||||
isSaving={updateUserMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1325
frontend-web-free/src/pages/VendorCompliance.jsx
Normal file
1325
frontend-web-free/src/pages/VendorCompliance.jsx
Normal file
File diff suppressed because it is too large
Load Diff
1143
frontend-web-free/src/pages/VendorDashboard.jsx
Normal file
1143
frontend-web-free/src/pages/VendorDashboard.jsx
Normal file
File diff suppressed because it is too large
Load Diff
510
frontend-web-free/src/pages/VendorDocumentReview.jsx
Normal file
510
frontend-web-free/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
frontend-web-free/src/pages/VendorInvoices.jsx
Normal file
114
frontend-web-free/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
frontend-web-free/src/pages/VendorManagement.jsx
Normal file
1147
frontend-web-free/src/pages/VendorManagement.jsx
Normal file
File diff suppressed because it is too large
Load Diff
998
frontend-web-free/src/pages/VendorMarketplace.jsx
Normal file
998
frontend-web-free/src/pages/VendorMarketplace.jsx
Normal file
@@ -0,0 +1,998 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Search, MapPin, Users, Star, DollarSign, TrendingUp, MessageSquare, CheckCircle, Award, Filter, Grid, List, Phone, Mail, Building2, Zap, ArrowRight, ChevronDown, ChevronUp, UserCheck, Briefcase, Shield, Crown, X, Edit2, Clock, Target, Handshake } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
export default function VendorMarketplace() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [regionFilter, setRegionFilter] = useState("all");
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
const [sortBy, setSortBy] = useState("rating");
|
||||
const [viewMode, setViewMode] = useState("grid");
|
||||
const [contactModal, setContactModal] = useState({ open: false, vendor: null });
|
||||
const [message, setMessage] = useState("");
|
||||
const [expandedVendors, setExpandedVendors] = useState({});
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-marketplace'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: vendors = [] } = useQuery({
|
||||
queryKey: ['approved-vendors'],
|
||||
queryFn: async () => {
|
||||
const allVendors = await base44.entities.Vendor.list();
|
||||
return allVendors.filter(v => v.approval_status === 'approved' && v.is_active);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: vendorRates = [] } = useQuery({
|
||||
queryKey: ['vendor-rates-marketplace'],
|
||||
queryFn: () => base44.entities.VendorRate.list(),
|
||||
});
|
||||
|
||||
const { data: staff = [] } = useQuery({
|
||||
queryKey: ['vendor-staff-count'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
});
|
||||
|
||||
const { data: events = [] } = useQuery({
|
||||
queryKey: ['events-vendor-marketplace'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: businesses = [] } = useQuery({
|
||||
queryKey: ['businesses-vendor-marketplace'],
|
||||
queryFn: () => base44.entities.Business.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const vendorsWithMetrics = useMemo(() => {
|
||||
return vendors.map(vendor => {
|
||||
const rates = vendorRates.filter(r => r.vendor_name === vendor.legal_name || r.vendor_id === vendor.id);
|
||||
const vendorStaff = staff.filter(s => s.vendor_name === vendor.legal_name);
|
||||
|
||||
const avgRate = rates.length > 0
|
||||
? rates.reduce((sum, r) => sum + (r.client_rate || 0), 0) / rates.length
|
||||
: 0;
|
||||
|
||||
const minRate = rates.length > 0
|
||||
? Math.min(...rates.map(r => r.client_rate || 999))
|
||||
: 0;
|
||||
|
||||
const rating = 4.5 + (Math.random() * 0.5);
|
||||
const completedJobs = Math.floor(Math.random() * 200) + 50;
|
||||
|
||||
const vendorEvents = events.filter(e =>
|
||||
e.vendor_name === vendor.legal_name ||
|
||||
e.vendor_id === vendor.id
|
||||
);
|
||||
|
||||
const uniqueClients = new Set(
|
||||
vendorEvents.map(e => e.business_name || e.client_email)
|
||||
).size;
|
||||
|
||||
const userSector = user?.sector || user?.company_name;
|
||||
const sectorClients = businesses.filter(b =>
|
||||
b.sector === userSector || b.area === user?.area
|
||||
);
|
||||
|
||||
const clientsInSector = new Set(
|
||||
vendorEvents
|
||||
.filter(e => sectorClients.some(sc =>
|
||||
sc.business_name === e.business_name ||
|
||||
sc.contact_name === e.client_name
|
||||
))
|
||||
.map(e => e.business_name || e.client_email)
|
||||
).size;
|
||||
|
||||
const ratesByCategory = rates.reduce((acc, rate) => {
|
||||
const category = rate.category || 'Other';
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(rate);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
...vendor,
|
||||
rates,
|
||||
ratesByCategory,
|
||||
avgRate,
|
||||
minRate,
|
||||
rating,
|
||||
completedJobs,
|
||||
staffCount: vendorStaff.length,
|
||||
responseTime: `${Math.floor(Math.random() * 3) + 1}h`,
|
||||
totalClients: uniqueClients,
|
||||
clientsInSector: clientsInSector,
|
||||
};
|
||||
});
|
||||
}, [vendors, vendorRates, staff, events, businesses, user]);
|
||||
|
||||
const filteredVendors = useMemo(() => {
|
||||
let filtered = vendorsWithMetrics;
|
||||
|
||||
if (searchTerm) {
|
||||
const lower = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(v =>
|
||||
v.legal_name?.toLowerCase().includes(lower) ||
|
||||
v.doing_business_as?.toLowerCase().includes(lower) ||
|
||||
v.service_specialty?.toLowerCase().includes(lower)
|
||||
);
|
||||
}
|
||||
|
||||
if (regionFilter !== "all") {
|
||||
filtered = filtered.filter(v => v.region === regionFilter);
|
||||
}
|
||||
|
||||
if (categoryFilter !== "all") {
|
||||
filtered = filtered.filter(v => v.service_specialty === categoryFilter);
|
||||
}
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case "rating":
|
||||
return b.rating - a.rating;
|
||||
case "price-low":
|
||||
return a.minRate - b.minRate;
|
||||
case "price-high":
|
||||
return b.avgRate - a.avgRate;
|
||||
case "staff":
|
||||
return b.staffCount - a.staffCount;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [vendorsWithMetrics, searchTerm, regionFilter, categoryFilter, sortBy]);
|
||||
|
||||
const preferredVendor = vendorsWithMetrics.find(v => v.id === user?.preferred_vendor_id);
|
||||
const otherVendors = filteredVendors.filter(v => v.id !== user?.preferred_vendor_id);
|
||||
|
||||
const uniqueRegions = [...new Set(vendors.map(v => v.region).filter(Boolean))];
|
||||
const uniqueCategories = [...new Set(vendors.map(v => v.service_specialty).filter(Boolean))];
|
||||
|
||||
const setPreferredMutation = useMutation({
|
||||
mutationFn: (vendor) => base44.auth.updateMe({
|
||||
preferred_vendor_id: vendor.id,
|
||||
preferred_vendor_name: vendor.legal_name || vendor.doing_business_as
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['current-user'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['current-user-marketplace'] });
|
||||
toast({
|
||||
title: "✅ Preferred Vendor Set",
|
||||
description: "All new orders will route to this vendor by default",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const removePreferredMutation = useMutation({
|
||||
mutationFn: () => base44.auth.updateMe({
|
||||
preferred_vendor_id: null,
|
||||
preferred_vendor_name: null
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['current-user'] });
|
||||
toast({
|
||||
title: "✅ Preferred Vendor Removed",
|
||||
description: "You can now select a new preferred vendor",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleContactVendor = (vendor) => {
|
||||
setContactModal({ open: true, vendor });
|
||||
setMessage(`Hi ${vendor.legal_name},\n\nI'm interested in your services for an upcoming event. Could you please provide more information about your availability and pricing?\n\nBest regards,\n${user?.full_name || 'Client'}`);
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!message.trim()) {
|
||||
toast({
|
||||
title: "Message required",
|
||||
description: "Please enter a message to send.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await base44.entities.Conversation.create({
|
||||
participants: [
|
||||
{ id: user?.id, name: user?.full_name, role: "client" },
|
||||
{ id: contactModal.vendor.id, name: contactModal.vendor.legal_name, role: "vendor" }
|
||||
],
|
||||
conversation_type: "client-vendor",
|
||||
is_group: false,
|
||||
subject: `Inquiry from ${user?.full_name || 'Client'}`,
|
||||
last_message: message.substring(0, 100),
|
||||
last_message_at: new Date().toISOString(),
|
||||
status: "active"
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "✅ Message sent!",
|
||||
description: `Your message has been sent to ${contactModal.vendor.legal_name}`,
|
||||
});
|
||||
|
||||
setContactModal({ open: false, vendor: null });
|
||||
setMessage("");
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to send message",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateOrder = (vendor) => {
|
||||
sessionStorage.setItem('selectedVendor', JSON.stringify({
|
||||
id: vendor.id,
|
||||
name: vendor.legal_name,
|
||||
rates: vendor.rates
|
||||
}));
|
||||
navigate(createPageUrl("CreateEvent"));
|
||||
toast({
|
||||
title: "Vendor selected",
|
||||
description: `${vendor.legal_name} will be used for this order.`,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleVendorRates = (vendorId) => {
|
||||
setExpandedVendors(prev => ({
|
||||
...prev,
|
||||
[vendorId]: !prev[vendorId]
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-6">
|
||||
<div className="max-w-[1600px] mx-auto space-y-6">
|
||||
|
||||
{/* Hero Header */}
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-slate-100 via-purple-50 to-blue-50 rounded-xl p-8 shadow-lg border border-slate-200">
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-14 h-14 bg-white shadow-md rounded-xl flex items-center justify-center border border-slate-200">
|
||||
<Building2 className="w-7 h-7 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-800">Vendor Marketplace</h1>
|
||||
<p className="text-slate-600 text-sm mt-1">Find the perfect vendor partner for your staffing needs</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-5">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-white shadow-sm rounded-lg border border-slate-200">
|
||||
<Users className="w-4 h-4 text-indigo-600" />
|
||||
<span className="text-slate-700 font-semibold">{filteredVendors.length} Active Vendors</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-white shadow-sm rounded-lg border border-slate-200">
|
||||
<Star className="w-4 h-4 text-amber-500 fill-amber-400" />
|
||||
<span className="text-slate-700 font-semibold">Verified & Approved</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preferred Vendor Section */}
|
||||
{preferredVendor ? (
|
||||
<Card className="border-2 border-[#0A39DF] bg-white shadow-lg overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-slate-200 pb-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-400 to-blue-500 rounded-xl flex items-center justify-center shadow-md">
|
||||
<Handshake className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div className="absolute -top-1 -right-1 w-6 h-6 bg-blue-500 rounded-full border-2 border-white flex items-center justify-center shadow-md">
|
||||
<DollarSign className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Badge className="bg-[#0A39DF] text-white font-bold px-3 py-1 mb-2">
|
||||
PREFERRED VENDOR
|
||||
</Badge>
|
||||
<h2 className="text-2xl font-bold text-slate-900">
|
||||
{preferredVendor.doing_business_as || preferredVendor.legal_name}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 mt-1">Your default vendor for all new orders</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removePreferredMutation.mutate()}
|
||||
disabled={removePreferredMutation.isPending}
|
||||
className="text-slate-600 hover:text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-6 gap-3 mb-5">
|
||||
{/* Stats Grid */}
|
||||
<div className="text-center p-4 bg-slate-50/50 rounded-lg border border-slate-200">
|
||||
<Users className="w-5 h-5 mx-auto mb-2 text-slate-600" />
|
||||
<p className="text-2xl font-bold text-slate-900">{preferredVendor.staffCount}</p>
|
||||
<p className="text-xs text-slate-600 mt-1 font-medium">Staff</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||
<Star className="w-5 h-5 mx-auto mb-2 text-amber-600 fill-amber-500" />
|
||||
<p className="text-2xl font-bold text-slate-900">{preferredVendor.rating.toFixed(1)}</p>
|
||||
<p className="text-xs text-slate-600 mt-1 font-medium">Rating</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-teal-50 rounded-lg border border-teal-200">
|
||||
<Target className="w-5 h-5 mx-auto mb-2 text-teal-600" />
|
||||
<p className="text-2xl font-bold text-slate-900">98%</p>
|
||||
<p className="text-xs text-slate-600 mt-1 font-medium">Fill Rate</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<Clock className="w-5 h-5 mx-auto mb-2 text-blue-600" />
|
||||
<p className="text-2xl font-bold text-slate-900">{preferredVendor.responseTime}</p>
|
||||
<p className="text-xs text-slate-600 mt-1 font-medium">Response</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<DollarSign className="w-5 h-5 mx-auto mb-2 text-blue-600" />
|
||||
<p className="text-2xl font-bold text-slate-900">${Math.round(preferredVendor.minRate)}</p>
|
||||
<p className="text-xs text-slate-600 mt-1 font-medium">From/hr</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<CheckCircle className="w-5 h-5 mx-auto mb-2 text-green-600" />
|
||||
<p className="text-2xl font-bold text-slate-900">{preferredVendor.completedJobs}</p>
|
||||
<p className="text-xs text-slate-600 mt-1 font-medium">Jobs</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Benefits Banner */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3 flex items-center gap-3">
|
||||
<div className="w-9 h-9 bg-white border border-green-200 shadow-sm rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Zap className="w-4 h-4 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-slate-800 text-sm">Priority Support</p>
|
||||
<p className="text-xs text-slate-600">Faster responses</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 flex items-center gap-3">
|
||||
<div className="w-9 h-9 bg-white border border-blue-200 shadow-sm rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Shield className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-slate-800 text-sm">Dedicated Manager</p>
|
||||
<p className="text-xs text-slate-600">Direct contact</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 flex items-center gap-3">
|
||||
<div className="w-9 h-9 bg-white border border-blue-200 shadow-sm rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<TrendingUp className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-slate-800 text-sm">Better Rates</p>
|
||||
<p className="text-xs text-slate-600">Volume pricing</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="border-2 border-dashed border-slate-300 bg-slate-50">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-slate-200 rounded-xl flex items-center justify-center">
|
||||
<Crown className="w-8 h-8 text-slate-400" />
|
||||
</div>
|
||||
<h3 className="font-bold text-xl text-slate-900 mb-2">
|
||||
Set Your Preferred Vendor
|
||||
</h3>
|
||||
<p className="text-slate-600 mb-6 max-w-2xl mx-auto">
|
||||
Choose a default vendor for faster ordering and streamlined operations. You'll get priority support and better rates.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg border border-slate-200">
|
||||
<Zap className="w-4 h-4 text-[#0A39DF]" />
|
||||
<span className="text-sm font-medium text-slate-700">Quick Orders</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg border border-slate-200">
|
||||
<Shield className="w-4 h-4 text-green-600" />
|
||||
<span className="text-sm font-medium text-slate-700">Priority Support</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg border border-slate-200">
|
||||
<Target className="w-4 h-4 text-indigo-600" />
|
||||
<span className="text-sm font-medium text-slate-700">Better Rates</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="border border-slate-200 bg-slate-50/50 hover:shadow-md transition-all">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Vendors</p>
|
||||
<p className="text-3xl font-bold text-slate-900 mb-0.5">{vendors.length}</p>
|
||||
<p className="text-slate-500 text-xs">Approved</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-white border border-slate-200 shadow-sm rounded-xl flex items-center justify-center">
|
||||
<Building2 className="w-6 h-6 text-slate-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-yellow-200 bg-yellow-50 hover:shadow-md transition-all">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Rating</p>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<p className="text-3xl font-bold text-slate-900">4.7</p>
|
||||
<Star className="w-5 h-5 text-amber-500 fill-amber-500" />
|
||||
</div>
|
||||
<p className="text-slate-500 text-xs">Average</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-white border border-yellow-200 shadow-sm rounded-xl flex items-center justify-center">
|
||||
<Award className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-teal-200 bg-teal-50 hover:shadow-md transition-all">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Fill Rate</p>
|
||||
<p className="text-3xl font-bold text-slate-900 mb-0.5">98%</p>
|
||||
<p className="text-slate-500 text-xs">Success rate</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-white border border-teal-200 shadow-sm rounded-xl flex items-center justify-center">
|
||||
<Target className="w-6 h-6 text-teal-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-blue-200 bg-blue-50 hover:shadow-md transition-all">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Response</p>
|
||||
<p className="text-3xl font-bold text-slate-900 mb-0.5">2h</p>
|
||||
<p className="text-slate-500 text-xs">Avg time</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-white border border-blue-200 shadow-sm rounded-xl flex items-center justify-center">
|
||||
<Clock className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="border border-slate-200 shadow-sm bg-white">
|
||||
<CardContent className="p-5">
|
||||
<div className="grid grid-cols-12 gap-4 items-end">
|
||||
<div className="col-span-5">
|
||||
<label className="text-xs font-semibold text-slate-700 mb-2 block">
|
||||
Search Vendors
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search by name, specialty, or location..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 h-10 border border-slate-300 focus:border-[#0A39DF] text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-semibold text-slate-700 mb-2 block">
|
||||
Region
|
||||
</label>
|
||||
<Select value={regionFilter} onValueChange={setRegionFilter}>
|
||||
<SelectTrigger className="h-10 border border-slate-300">
|
||||
<SelectValue placeholder="All Regions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Regions</SelectItem>
|
||||
{uniqueRegions.map(region => (
|
||||
<SelectItem key={region} value={region}>{region}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-semibold text-slate-700 mb-2 block">
|
||||
Specialty
|
||||
</label>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="h-10 border border-slate-300">
|
||||
<SelectValue placeholder="All" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Specialties</SelectItem>
|
||||
{uniqueCategories.map(cat => (
|
||||
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-semibold text-slate-700 mb-2 block">
|
||||
Sort By
|
||||
</label>
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="h-10 border border-slate-300">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="rating">⭐ Highest Rated</SelectItem>
|
||||
<SelectItem value="price-low">💰 Lowest Price</SelectItem>
|
||||
<SelectItem value="price-high">💎 Premium</SelectItem>
|
||||
<SelectItem value="staff">👥 Most Staff</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 flex items-end">
|
||||
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-full">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("grid")}
|
||||
className={`flex-1 ${viewMode === "grid" ? "bg-[#0A39DF]" : ""}`}
|
||||
>
|
||||
<Grid className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("list")}
|
||||
className={`flex-1 ${viewMode === "list" ? "bg-[#0A39DF]" : ""}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Other Vendors Section Title */}
|
||||
{preferredVendor && (
|
||||
<div className="flex items-center gap-3 my-2">
|
||||
<div className="h-px flex-1 bg-slate-200" />
|
||||
<h2 className="text-base font-bold text-slate-700">
|
||||
Other Available Vendors
|
||||
</h2>
|
||||
<div className="h-px flex-1 bg-slate-200" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vendors Grid */}
|
||||
{viewMode === "grid" ? (
|
||||
<div className="space-y-6">
|
||||
{otherVendors.map((vendor) => {
|
||||
const isExpanded = expandedVendors[vendor.id];
|
||||
|
||||
return (
|
||||
<Card key={vendor.id} className="bg-white border border-slate-200 hover:border-blue-300 hover:shadow-lg transition-all group">
|
||||
<CardHeader className="bg-gradient-to-br from-slate-50 to-blue-50/30 border-b border-slate-200 pb-4">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="relative">
|
||||
<Avatar className="w-16 h-16 bg-blue-100 shadow-lg ring-2 ring-blue-200">
|
||||
{vendor.company_logo ? (
|
||||
<AvatarImage src={vendor.company_logo} alt={vendor.legal_name} />
|
||||
) : null}
|
||||
<AvatarFallback className="text-blue-700 text-xl font-bold bg-blue-100">
|
||||
{vendor.legal_name?.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute -bottom-1 -right-1 w-6 h-6 bg-green-500 rounded-full border-2 border-white flex items-center justify-center shadow-md">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CardTitle className="text-xl font-bold text-slate-800 group-hover:text-blue-700 transition-colors">
|
||||
{vendor.legal_name}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1.5 bg-yellow-50 px-3 py-1.5 rounded-full border border-yellow-200">
|
||||
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
||||
<span className="text-sm font-bold text-slate-800">{vendor.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vendor.doing_business_as && (
|
||||
<p className="text-xs text-slate-500 mb-3 italic">DBA: {vendor.doing_business_as}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{vendor.service_specialty && (
|
||||
<Badge className="bg-blue-100 text-blue-700 border border-blue-200">
|
||||
{vendor.service_specialty}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5 text-sm text-slate-700">
|
||||
<MapPin className="w-4 h-4 text-slate-500" />
|
||||
{vendor.region || vendor.city}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-sm text-slate-700">
|
||||
<Users className="w-4 h-4 text-slate-500" />
|
||||
{vendor.staffCount} Staff
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-sm text-slate-700">
|
||||
<Clock className="w-4 h-4 text-teal-600" />
|
||||
{vendor.responseTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-xl shadow-sm text-center min-w-[140px]">
|
||||
<p className="text-slate-600 text-[10px] mb-1 font-semibold uppercase tracking-wide">Starting from</p>
|
||||
<p className="text-3xl font-bold text-slate-900 mb-1">${vendor.minRate}</p>
|
||||
<p className="text-slate-600 text-xs">per hour</p>
|
||||
</div>
|
||||
|
||||
{vendor.clientsInSector > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 shadow-sm min-w-[140px]">
|
||||
<div className="flex items-center justify-center gap-2 mb-1">
|
||||
<UserCheck className="w-5 h-5 text-blue-600" />
|
||||
<span className="text-2xl font-bold text-slate-900">{vendor.clientsInSector}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-600 font-bold text-center uppercase tracking-wide">
|
||||
in your area
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-green-50 text-green-700 border-green-200 border px-3 py-1.5 text-xs">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
{vendor.completedJobs} jobs
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-slate-300 bg-slate-50/50 px-3 py-1.5 text-xs font-semibold">
|
||||
{vendor.rates.length} services
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<div className="px-5 py-4 bg-slate-50/50 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<Collapsible open={isExpanded} onOpenChange={() => toggleVendorRates(vendor.id)} className="flex-1">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-auto px-4 py-2 hover:bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 bg-white border border-slate-200 shadow-sm rounded-lg flex items-center justify-center">
|
||||
<TrendingUp className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="font-bold text-slate-800 text-base">Compare Rates</span>
|
||||
<span className="text-xs text-slate-500 block">{vendor.rates.length} services</span>
|
||||
</div>
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4 text-slate-400 ml-2" /> : <ChevronDown className="w-4 h-4 text-slate-400 ml-2" />}
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => setPreferredMutation.mutate(vendor)}
|
||||
disabled={setPreferredMutation.isPending}
|
||||
className="bg-cyan-100 hover:bg-cyan-200 text-slate-800 font-bold shadow-sm border border-cyan-200"
|
||||
>
|
||||
<Award className="w-4 h-4 mr-2" />
|
||||
Set as Preferred
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleContactVendor(vendor)}
|
||||
className="bg-amber-50 hover:bg-amber-100 text-slate-800 border border-amber-200"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Contact
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleCreateOrder(vendor)}
|
||||
className="bg-purple-100 hover:bg-purple-200 text-slate-800 shadow-sm border border-purple-200"
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Order Now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible open={isExpanded}>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="p-6 bg-slate-50/50">
|
||||
<div className="space-y-4">
|
||||
{Object.entries(vendor.ratesByCategory).map(([category, categoryRates]) => (
|
||||
<div key={category} className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="bg-gradient-to-r from-slate-100 to-purple-50 px-5 py-3 border-b border-slate-200">
|
||||
<h4 className="font-bold text-slate-800 text-sm flex items-center gap-2">
|
||||
<Briefcase className="w-4 h-4 text-slate-600" />
|
||||
{category}
|
||||
<Badge className="bg-slate-200 text-slate-700 border-0 ml-auto">
|
||||
{categoryRates.length}
|
||||
</Badge>
|
||||
</h4>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{categoryRates.map((rate, idx) => {
|
||||
return (
|
||||
<div key={rate.id} className="p-4 hover:bg-blue-50/30 transition-all">
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center font-bold text-blue-700 text-sm">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<h5 className="font-bold text-slate-900 text-base">{rate.role_name}</h5>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl px-6 py-3 shadow-sm">
|
||||
<p className="text-3xl font-bold text-slate-900">${rate.client_rate?.toFixed(0)}</p>
|
||||
<p className="text-slate-600 text-xs text-center mt-1">per hour</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-2 border-slate-200 shadow-xl">
|
||||
<CardContent className="p-0">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gradient-to-r from-slate-50 to-blue-50 border-b-2 border-slate-200">
|
||||
<tr>
|
||||
<th className="text-left py-5 px-5 text-xs font-bold text-slate-700 uppercase">Vendor</th>
|
||||
<th className="text-left py-5 px-5 text-xs font-bold text-slate-700 uppercase">Specialty</th>
|
||||
<th className="text-left py-5 px-5 text-xs font-bold text-slate-700 uppercase">Location</th>
|
||||
<th className="text-center py-5 px-5 text-xs font-bold text-slate-700 uppercase">Rating</th>
|
||||
<th className="text-center py-5 px-5 text-xs font-bold text-slate-700 uppercase">Clients</th>
|
||||
<th className="text-center py-5 px-5 text-xs font-bold text-slate-700 uppercase">Staff</th>
|
||||
<th className="text-center py-5 px-5 text-xs font-bold text-slate-700 uppercase">Min Rate</th>
|
||||
<th className="text-center py-5 px-5 text-xs font-bold text-slate-700 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{otherVendors.map((vendor) => (
|
||||
<tr key={vendor.id} className="border-b border-slate-100 hover:bg-blue-50/30 transition-all">
|
||||
<td className="py-5 px-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="w-12 h-12 bg-blue-100 shadow-md">
|
||||
{vendor.company_logo ? (
|
||||
<AvatarImage src={vendor.company_logo} alt={vendor.legal_name} />
|
||||
) : null}
|
||||
<AvatarFallback className="text-blue-700 font-bold text-lg bg-blue-100">
|
||||
{vendor.legal_name?.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-bold text-slate-800">{vendor.legal_name}</p>
|
||||
<p className="text-xs text-slate-500">{vendor.completedJobs} jobs completed</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-5 px-5 text-sm text-slate-700">{vendor.service_specialty || '—'}</td>
|
||||
<td className="py-5 px-5">
|
||||
<span className="flex items-center gap-1.5 text-sm text-slate-700">
|
||||
<MapPin className="w-4 h-4 text-slate-500" />
|
||||
{vendor.region}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-5 px-5 text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-yellow-50 px-3 py-1.5 rounded-full border border-yellow-200">
|
||||
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
||||
<span className="font-bold text-slate-800">{vendor.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-5 px-5 text-center">
|
||||
{vendor.clientsInSector > 0 ? (
|
||||
<Badge className="bg-blue-100 text-blue-700 border border-blue-200">
|
||||
<UserCheck className="w-3 h-3 mr-1" />
|
||||
{vendor.clientsInSector}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-slate-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-5 px-5 text-center">
|
||||
<Badge variant="outline" className="font-bold border-slate-300">{vendor.staffCount}</Badge>
|
||||
</td>
|
||||
<td className="py-5 px-5 text-center">
|
||||
<div className="inline-flex flex-col bg-blue-50 border border-blue-200 px-4 py-2 rounded-xl">
|
||||
<span className="font-bold text-xl text-slate-900">${vendor.minRate}</span>
|
||||
<span className="text-xs text-slate-600">/hour</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-5 px-5">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPreferredMutation.mutate(vendor)}
|
||||
disabled={setPreferredMutation.isPending}
|
||||
className="bg-cyan-100 hover:bg-cyan-200 text-slate-800 border border-cyan-200"
|
||||
>
|
||||
<Award className="w-3 h-3 mr-1" />
|
||||
Set Preferred
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleContactVendor(vendor)}
|
||||
className="bg-amber-50 hover:bg-amber-100 text-slate-800 border border-amber-200"
|
||||
>
|
||||
<MessageSquare className="w-3 h-3 mr-1" />
|
||||
Contact
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{otherVendors.length === 0 && !preferredVendor && (
|
||||
<div className="text-center py-16 bg-white rounded-2xl border-2 border-dashed border-slate-300">
|
||||
<Building2 className="w-16 h-16 mx-auto mb-4 text-slate-300" />
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-2">No vendors found</h3>
|
||||
<p className="text-slate-600 mb-5">Try adjusting your filters</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearchTerm("");
|
||||
setRegionFilter("all");
|
||||
setCategoryFilter("all");
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact Modal */}
|
||||
<Dialog open={contactModal.open} onOpenChange={(open) => setContactModal({ open, vendor: null })}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold text-[#1C323E]">
|
||||
Contact {contactModal.vendor?.legal_name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Start a conversation and get staffing help within hours
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-4">
|
||||
<div className="flex items-center gap-4 p-5 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border-2 border-blue-200">
|
||||
<Avatar className="w-16 h-16 bg-blue-100 ring-2 ring-white shadow-md">
|
||||
{contactModal.vendor?.company_logo ? (
|
||||
<AvatarImage src={contactModal.vendor?.company_logo} alt={contactModal.vendor?.legal_name} />
|
||||
) : null}
|
||||
<AvatarFallback className="text-blue-700 text-xl font-bold bg-blue-100">
|
||||
{contactModal.vendor?.legal_name?.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-[#1C323E] text-lg mb-2">{contactModal.vendor?.legal_name}</h4>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge className="bg-white text-slate-700">
|
||||
<MapPin className="w-3 h-3 mr-1" />
|
||||
{contactModal.vendor?.region}
|
||||
</Badge>
|
||||
<Badge className="bg-white text-slate-700">
|
||||
<Users className="w-3 h-3 mr-1" />
|
||||
{contactModal.vendor?.staffCount} staff
|
||||
</Badge>
|
||||
<Badge className="bg-amber-50 text-amber-700">
|
||||
<Star className="w-3 h-3 mr-1 fill-amber-500" />
|
||||
{contactModal.vendor?.rating?.toFixed(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-bold text-slate-700 mb-2 block">
|
||||
<MessageSquare className="w-4 h-4 inline mr-2 text-[#0A39DF]" />
|
||||
Your Message
|
||||
</label>
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={8}
|
||||
placeholder="Enter your message..."
|
||||
className="border-2 border-slate-200 focus:border-[#0A39DF]"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-2 bg-blue-50 p-2 rounded">
|
||||
💡 <strong>Tip:</strong> Include event date, location, and staff needed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setContactModal({ open: false, vendor: null })}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSendMessage} className="bg-[#0A39DF] hover:bg-blue-700">
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Send Message
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
752
frontend-web-free/src/pages/VendorOnboarding.jsx
Normal file
752
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
509
frontend-web-free/src/pages/VendorOrders.jsx
Normal file
509
frontend-web-free/src/pages/VendorOrders.jsx
Normal file
@@ -0,0 +1,509 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search, Eye, Edit, Copy, UserCheck, Zap, Clock, Users, RefreshCw, Calendar as CalendarIcon, AlertTriangle, List, LayoutGrid, CheckCircle, FileText, X, MapPin } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { format, parseISO, isValid } from "date-fns";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import SmartAssignModal from "../components/events/SmartAssignModal";
|
||||
import { autoFillShifts } from "../components/scheduling/SmartAssignmentEngine";
|
||||
import { detectAllConflicts, ConflictAlert } from "../components/scheduling/ConflictDetection";
|
||||
|
||||
const safeParseDate = (dateString) => {
|
||||
if (!dateString) return null;
|
||||
try {
|
||||
// If date is in format YYYY-MM-DD, parse it without timezone conversion
|
||||
if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||
const [year, month, day] = dateString.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
return isValid(date) ? date : null;
|
||||
}
|
||||
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
|
||||
return isValid(date) ? date : null;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
const safeFormatDate = (dateString, formatStr) => {
|
||||
const date = safeParseDate(dateString);
|
||||
if (!date) return "-";
|
||||
try { return format(date, formatStr); } catch { return "-"; }
|
||||
};
|
||||
|
||||
const convertTo12Hour = (time24) => {
|
||||
if (!time24) return "-";
|
||||
try {
|
||||
const [hours, minutes] = time24.split(':');
|
||||
const hour = parseInt(hours);
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = hour % 12 || 12;
|
||||
return `${hour12}:${minutes} ${ampm}`;
|
||||
} catch {
|
||||
return time24;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (event, hasConflicts) => {
|
||||
if (event.is_rapid) {
|
||||
return (
|
||||
<div className="relative inline-flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md">
|
||||
<Zap className="w-3.5 h-3.5 fill-white" />
|
||||
RAPID
|
||||
{hasConflicts && (
|
||||
<AlertTriangle className="w-3 h-3 absolute -top-1 -right-1 text-orange-500 bg-white rounded-full p-0.5" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
'Draft': { bg: 'bg-slate-500', icon: FileText },
|
||||
'Pending': { bg: 'bg-amber-500', icon: Clock },
|
||||
'Partial Staffed': { bg: 'bg-orange-500', icon: AlertTriangle },
|
||||
'Fully Staffed': { bg: 'bg-emerald-500', icon: CheckCircle },
|
||||
'Active': { bg: 'bg-blue-500', icon: Users },
|
||||
'Completed': { bg: 'bg-slate-400', icon: CheckCircle },
|
||||
'Canceled': { bg: 'bg-red-500', icon: X },
|
||||
};
|
||||
|
||||
const config = statusConfig[event.status] || { bg: 'bg-slate-400', icon: Clock };
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className={`relative inline-flex items-center gap-2 ${config.bg} text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{event.status}
|
||||
{hasConflicts && (
|
||||
<AlertTriangle className="w-3 h-3 absolute -top-1 -right-1 text-orange-500 bg-white rounded-full p-0.5" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function VendorOrders() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [viewMode, setViewMode] = useState("table");
|
||||
const [showConflicts, setShowConflicts] = useState(true);
|
||||
const [assignModal, setAssignModal] = useState({ open: false, event: null });
|
||||
|
||||
const [assignmentOptions] = useState({
|
||||
prioritizeSkill: true,
|
||||
prioritizeReliability: true,
|
||||
prioritizeVendor: true,
|
||||
prioritizeFatigue: true,
|
||||
prioritizeCompliance: true,
|
||||
prioritizeProximity: true,
|
||||
prioritizeCost: false,
|
||||
});
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-vendor-orders'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: allEvents = [] } = useQuery({
|
||||
queryKey: ['all-events-vendor'],
|
||||
queryFn: () => base44.entities.Event.list('-date'),
|
||||
});
|
||||
|
||||
const { data: allStaff = [] } = useQuery({
|
||||
queryKey: ['staff-for-auto-assign'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
});
|
||||
|
||||
const { data: vendorRates = [] } = useQuery({
|
||||
queryKey: ['vendor-rates-auto-assign'],
|
||||
queryFn: () => base44.entities.VendorRate.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const updateEventMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Event.update(id, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['all-events-vendor'] }),
|
||||
});
|
||||
|
||||
const autoAssignMutation = useMutation({
|
||||
mutationFn: async (event) => {
|
||||
const assignments = await autoFillShifts(event, allStaff, vendorEvents, vendorRates, assignmentOptions);
|
||||
if (assignments.length === 0) throw new Error("No suitable staff found");
|
||||
|
||||
const updatedAssignedStaff = [...(event.assigned_staff || []), ...assignments];
|
||||
const updatedShifts = (event.shifts || []).map(shift => {
|
||||
const updatedRoles = (shift.roles || []).map(role => {
|
||||
const roleAssignments = assignments.filter(a => a.role === role.role);
|
||||
return { ...role, assigned: (role.assigned || 0) + roleAssignments.length };
|
||||
});
|
||||
return { ...shift, roles: updatedRoles };
|
||||
});
|
||||
|
||||
const totalRequested = updatedShifts.reduce((accShift, shift) => {
|
||||
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
|
||||
}, 0);
|
||||
|
||||
const totalAssigned = updatedAssignedStaff.length;
|
||||
let newStatus = event.status;
|
||||
|
||||
if (totalAssigned >= totalRequested && totalRequested > 0) {
|
||||
newStatus = 'Fully Staffed';
|
||||
} else if (totalAssigned > 0 && totalAssigned < totalRequested) {
|
||||
newStatus = 'Partial Staffed';
|
||||
} else if (totalAssigned === 0) {
|
||||
newStatus = 'Pending';
|
||||
}
|
||||
|
||||
await base44.entities.Event.update(event.id, {
|
||||
assigned_staff: updatedAssignedStaff,
|
||||
shifts: updatedShifts,
|
||||
requested: (event.requested || 0) + assignments.length,
|
||||
status: newStatus,
|
||||
});
|
||||
|
||||
return assignments.length;
|
||||
},
|
||||
onSuccess: (count) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['all-events-vendor'] });
|
||||
toast({ title: "✅ Auto-Assigned", description: `Assigned ${count} staff automatically` });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({ title: "⚠️ Auto-Assign Failed", description: error.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const vendorEvents = useMemo(() => {
|
||||
return allEvents.filter(e =>
|
||||
e.vendor_name === user?.company_name ||
|
||||
e.vendor_id === user?.id ||
|
||||
e.created_by === user?.email
|
||||
);
|
||||
}, [allEvents, user]);
|
||||
|
||||
const eventsWithConflicts = useMemo(() => {
|
||||
return vendorEvents.map(event => {
|
||||
const conflicts = detectAllConflicts(event, vendorEvents);
|
||||
return { ...event, detected_conflicts: conflicts };
|
||||
});
|
||||
}, [vendorEvents]);
|
||||
|
||||
const totalConflicts = eventsWithConflicts.reduce((sum, e) => sum + (e.detected_conflicts?.length || 0), 0);
|
||||
|
||||
const filteredEvents = useMemo(() => {
|
||||
let filtered = eventsWithConflicts;
|
||||
|
||||
if (activeTab === "upcoming") filtered = filtered.filter(e => { const eventDate = safeParseDate(e.date); return eventDate && eventDate > new Date(); });
|
||||
else if (activeTab === "active") filtered = filtered.filter(e => e.status === "Active");
|
||||
else if (activeTab === "past") filtered = filtered.filter(e => e.status === "Completed");
|
||||
else if (activeTab === "conflicts") filtered = filtered.filter(e => e.detected_conflicts && e.detected_conflicts.length > 0);
|
||||
|
||||
if (searchTerm) {
|
||||
const lower = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(e =>
|
||||
e.event_name?.toLowerCase().includes(lower) ||
|
||||
e.business_name?.toLowerCase().includes(lower) ||
|
||||
e.hub?.toLowerCase().includes(lower)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [eventsWithConflicts, searchTerm, activeTab]);
|
||||
|
||||
const getAssignmentStatus = (event) => {debugger;
|
||||
const totalRequested = event.shifts?.reduce((accShift, shift) => {
|
||||
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
|
||||
}, 0) || 0;
|
||||
|
||||
const assigned = event.assigned_staff?.length || 0;
|
||||
const fillPercent = totalRequested > 0 ? Math.round((assigned / totalRequested) * 100) : 0;
|
||||
|
||||
if (assigned === 0) return { color: 'bg-slate-100 text-slate-600', text: '0', percent: '0%', status: 'empty' };
|
||||
if (totalRequested > 0 && assigned >= totalRequested) return { color: 'bg-emerald-500 text-white', text: assigned, percent: '100%', status: 'full' };
|
||||
if (totalRequested > 0 && assigned < totalRequested) return { color: 'bg-orange-500 text-white', text: assigned, percent: `${fillPercent}%`, status: 'partial' };
|
||||
return { color: 'bg-slate-500 text-white', text: assigned, percent: '0%', status: 'partial' };
|
||||
};
|
||||
|
||||
const getTabCount = (tab) => {
|
||||
if (tab === "all") return vendorEvents.length;
|
||||
if (tab === "conflicts") return eventsWithConflicts.filter(e => e.detected_conflicts && e.detected_conflicts.length > 0).length;
|
||||
if (tab === "upcoming") return vendorEvents.filter(e => { const eventDate = safeParseDate(e.date); return eventDate && eventDate > new Date(); }).length;
|
||||
if (tab === "active") return vendorEvents.filter(e => e.status === "Active").length;
|
||||
if (tab === "past") return vendorEvents.filter(e => e.status === "Completed").length;
|
||||
return 0;
|
||||
};
|
||||
|
||||
// The original handleAutoAssignEvent is removed as the button now opens the modal directly.
|
||||
// const handleAutoAssignEvent = (event) => autoAssignMutation.mutate(event);
|
||||
|
||||
const getEventTimes = (event) => {
|
||||
const firstShift = event.shifts?.[0];
|
||||
const rolesInFirstShift = firstShift?.roles || [];
|
||||
|
||||
let startTime = null;
|
||||
let endTime = null;
|
||||
|
||||
if (rolesInFirstShift.length > 0) {
|
||||
startTime = rolesInFirstShift[0].start_time || null;
|
||||
endTime = rolesInFirstShift[0].end_time || null;
|
||||
}
|
||||
|
||||
return {
|
||||
startTime: startTime ? convertTo12Hour(startTime) : "-",
|
||||
endTime: endTime ? convertTo12Hour(endTime) : "-"
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-[1800px] mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Order Management</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">View, assign, and track all your orders</p>
|
||||
</div>
|
||||
|
||||
{showConflicts && totalConflicts > 0 && (
|
||||
<Alert className="mb-6 border-2 border-orange-500 bg-orange-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<AlertDescription className="font-semibold text-orange-900">
|
||||
{totalConflicts} scheduling conflict{totalConflicts !== 1 ? 's' : ''} detected
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => setShowConflicts(false)} className="flex-shrink-0">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card className="border border-red-200 bg-red-50">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-red-500 rounded-lg flex items-center justify-center">
|
||||
<Zap className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-red-600 font-semibold uppercase">RAPID</p>
|
||||
<p className="text-2xl font-bold text-red-700">{vendorEvents.filter(e => e.is_rapid).length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-amber-200 bg-amber-50">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
|
||||
<Clock className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-amber-600 font-semibold uppercase">REQUESTED</p>
|
||||
<p className="text-2xl font-bold text-amber-700">{vendorEvents.filter(e => e.status === 'Pending' || e.status === 'Draft').length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-orange-200 bg-orange-50">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-orange-600 font-semibold uppercase">PARTIAL</p>
|
||||
<p className="text-2xl font-bold text-orange-700">{vendorEvents.filter(e => {
|
||||
const status = getAssignmentStatus(e);
|
||||
return status.status === 'partial';
|
||||
}).length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-emerald-200 bg-emerald-50">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-emerald-500 rounded-lg flex items-center justify-center">
|
||||
<CheckCircle className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-emerald-600 font-semibold uppercase">FULLY STAFFED</p>
|
||||
<p className="text-2xl font-bold text-emerald-700">{vendorEvents.filter(e => {
|
||||
const status = getAssignmentStatus(e);
|
||||
return status.status === 'full';
|
||||
}).length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 mb-6 flex items-center gap-4 border shadow-sm">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input placeholder="Search by event, business, or location..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="pl-10 border-slate-200 h-10" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant={viewMode === "table" ? "default" : "outline"} size="sm" onClick={() => setViewMode("table")} className={viewMode === "table" ? "bg-[#0A39DF]" : ""}>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant={viewMode === "scheduler" ? "default" : "outline"} size="sm" onClick={() => setViewMode("scheduler")} className={viewMode === "scheduler" ? "bg-[#0A39DF]" : ""}>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
|
||||
<TabsList className="bg-white border">
|
||||
<TabsTrigger value="all">All ({getTabCount("all")})</TabsTrigger>
|
||||
<TabsTrigger value="conflicts" className="data-[state=active]:bg-orange-500 data-[state=active]:text-white">
|
||||
<AlertTriangle className="w-4 h-4 mr-2" />
|
||||
Conflicts ({getTabCount("conflicts")})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="upcoming">Upcoming ({getTabCount("upcoming")})</TabsTrigger>
|
||||
<TabsTrigger value="active">Active ({getTabCount("active")})</TabsTrigger>
|
||||
<TabsTrigger value="past">Past ({getTabCount("past")})</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 hover:bg-slate-50 border-b">
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide h-10">BUSINESS</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">HUB</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">EVENT</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">DATE & TIME</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">STATUS</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">REQUESTED</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ASSIGNED</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">INVOICE</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ACTIONS</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredEvents.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={9} className="text-center py-12 text-slate-500"><CalendarIcon className="w-12 h-12 mx-auto mb-3 text-slate-300" /><p className="font-medium">No orders found</p></TableCell></TableRow>
|
||||
) : (
|
||||
filteredEvents.map((event) => {
|
||||
const assignmentStatus = getAssignmentStatus(event);
|
||||
const showAutoButton = assignmentStatus.status !== 'full' && event.status !== 'Canceled' && event.status !== 'Completed';
|
||||
const hasConflicts = event.detected_conflicts && event.detected_conflicts.length > 0;
|
||||
const eventTimes = getEventTimes(event);
|
||||
const eventDate = safeParseDate(event.date);
|
||||
const dayOfWeek = eventDate ? format(eventDate, 'EEEE') : '';
|
||||
const invoiceReady = event.status === "Completed";
|
||||
|
||||
return (
|
||||
<React.Fragment key={event.id}>
|
||||
<TableRow className="hover:bg-slate-50 transition-colors border-b">
|
||||
<TableCell className="py-3">
|
||||
<p className="text-sm text-slate-700 font-medium">{event.business_name || "—"}</p>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<div className="flex items-center gap-1.5 text-sm text-slate-500">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
{event.hub || event.event_location || "Main Hub"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<p className="font-semibold text-slate-900 text-sm">{event.event_name}</p>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm text-slate-900 font-semibold">{eventDate ? format(eventDate, 'MM.dd.yyyy') : '-'}</p>
|
||||
<p className="text-xs text-slate-500">{dayOfWeek}</p>
|
||||
<div className="flex items-center gap-1 text-xs text-slate-600 mt-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{eventTimes.startTime} - {eventTimes.endTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
{getStatusBadge(event, hasConflicts)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-3">
|
||||
<span className="font-semibold text-slate-700 text-sm">{event.requested || 0}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-3">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Badge className={`${assignmentStatus.color} font-bold px-3 py-1 rounded-full text-xs`}>
|
||||
{assignmentStatus.text}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-slate-500 font-medium">{assignmentStatus.percent}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-3">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${invoiceReady ? 'bg-blue-100' : 'bg-slate-100'}`}>
|
||||
<FileText className={`w-5 h-5 ${invoiceReady ? 'text-blue-600' : 'text-slate-400'}`} />
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setAssignModal({ open: true, event: event })}
|
||||
className="h-8 px-2 hover:bg-slate-100"
|
||||
title="Smart Assign"
|
||||
>
|
||||
<UserCheck className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(createPageUrl(`EventDetail?id=${event.id}`))}
|
||||
className="hover:bg-slate-100 h-8 w-8"
|
||||
title="View"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
|
||||
className="hover:bg-slate-100 h-8 w-8"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{hasConflicts && activeTab === "conflicts" && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="bg-orange-50/50 py-4">
|
||||
<ConflictAlert conflicts={event.detected_conflicts} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SmartAssignModal
|
||||
open={assignModal.open}
|
||||
onClose={() => setAssignModal({ open: false, event: null })}
|
||||
event={assignModal.event}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
frontend-web-free/src/pages/VendorPerformance.jsx
Normal file
112
frontend-web-free/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
frontend-web-free/src/pages/VendorRateCard.jsx
Normal file
776
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
1188
frontend-web-free/src/pages/VendorRates.jsx
Normal file
1188
frontend-web-free/src/pages/VendorRates.jsx
Normal file
File diff suppressed because it is too large
Load Diff
152
frontend-web-free/src/pages/VendorStaff.jsx
Normal file
152
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
267
frontend-web-free/src/pages/WorkerShiftProposals.jsx
Normal file
267
frontend-web-free/src/pages/WorkerShiftProposals.jsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import React from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Calendar, Clock, MapPin, DollarSign, CheckCircle, XCircle, AlertCircle } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export default function WorkerShiftProposals() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [declineReason, setDeclineReason] = React.useState({});
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-proposals'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: proposals = [] } = useQuery({
|
||||
queryKey: ['shift-proposals', user?.id],
|
||||
queryFn: async () => {
|
||||
if (!user?.id) return [];
|
||||
const staff = await base44.entities.Staff.filter({ email: user.email });
|
||||
if (staff.length === 0) return [];
|
||||
return base44.entities.ShiftProposal.filter({ staff_id: staff[0].id });
|
||||
},
|
||||
enabled: !!user?.id,
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const respondMutation = useMutation({
|
||||
mutationFn: async ({ proposalId, status, reason }) => {
|
||||
const proposal = proposals.find(p => p.id === proposalId);
|
||||
|
||||
await base44.entities.ShiftProposal.update(proposalId, {
|
||||
proposal_status: status,
|
||||
responded_at: new Date().toISOString(),
|
||||
decline_reason: reason || null,
|
||||
});
|
||||
|
||||
if (status === 'ACCEPTED') {
|
||||
// Update event with confirmed assignment
|
||||
const event = await base44.entities.Event.list();
|
||||
const targetEvent = event.find(e => e.id === proposal.event_id);
|
||||
|
||||
if (targetEvent) {
|
||||
const updatedStaff = [
|
||||
...(targetEvent.assigned_staff || []),
|
||||
{
|
||||
staff_id: proposal.staff_id,
|
||||
staff_name: proposal.staff_name,
|
||||
role: proposal.role,
|
||||
email: user.email,
|
||||
}
|
||||
];
|
||||
|
||||
await base44.entities.Event.update(proposal.event_id, {
|
||||
assigned_staff: updatedStaff,
|
||||
status: 'Confirmed',
|
||||
});
|
||||
}
|
||||
|
||||
// Update availability
|
||||
const availability = await base44.entities.WorkerAvailability.filter({ staff_id: proposal.staff_id });
|
||||
if (availability.length > 0) {
|
||||
const current = availability[0];
|
||||
await base44.entities.WorkerAvailability.update(current.id, {
|
||||
scheduled_hours_this_period: (current.scheduled_hours_this_period || 0) + 8,
|
||||
need_work_index: Math.max(0, current.need_work_index - 10),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shift-proposals'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
|
||||
toast({
|
||||
title: variables.status === 'ACCEPTED' ? "✅ Shift Accepted" : "Shift Declined",
|
||||
description: variables.status === 'ACCEPTED'
|
||||
? "The shift has been added to your schedule"
|
||||
: "The vendor will be notified of your decision",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const pendingProposals = proposals.filter(p => p.proposal_status === 'PENDING_WORKER_CONFIRMATION');
|
||||
const pastProposals = proposals.filter(p => p.proposal_status !== 'PENDING_WORKER_CONFIRMATION');
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Shift Requests</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Review and respond to shift offers from vendors</p>
|
||||
</div>
|
||||
|
||||
{/* Pending Requests */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold text-slate-900">Pending Requests ({pendingProposals.length})</h2>
|
||||
|
||||
{pendingProposals.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-slate-300" />
|
||||
<p className="text-slate-500 font-medium">No pending shift requests</p>
|
||||
<p className="text-sm text-slate-400 mt-1">New offers will appear here</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
pendingProposals.map((proposal) => {
|
||||
const deadline = new Date(proposal.response_deadline);
|
||||
const isUrgent = deadline < new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
return (
|
||||
<Card key={proposal.id} className={`border-2 ${isUrgent ? 'border-orange-300 bg-orange-50' : 'border-blue-300 bg-blue-50'}`}>
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{proposal.event_name}</CardTitle>
|
||||
<p className="text-sm text-slate-600 mt-1">{proposal.role}</p>
|
||||
</div>
|
||||
{proposal.was_marked_unavailable && (
|
||||
<Badge variant="outline" className="bg-yellow-100 text-yellow-800 border-yellow-300">
|
||||
Override Unavailable
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-slate-500" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Date</p>
|
||||
<p className="text-sm font-semibold">{format(new Date(proposal.shift_date), 'MMM d, yyyy')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-slate-500" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Time</p>
|
||||
<p className="text-sm font-semibold">{proposal.start_time} - {proposal.end_time}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-slate-500" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Location</p>
|
||||
<p className="text-sm font-semibold">{proposal.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-slate-500" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Pay</p>
|
||||
<p className="text-sm font-semibold">${proposal.total_pay}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUrgent && (
|
||||
<div className="bg-orange-100 border border-orange-300 rounded-lg p-3">
|
||||
<p className="text-sm text-orange-800 font-medium">
|
||||
⏰ Respond by {format(deadline, 'MMM d, h:mm a')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{declineReason[proposal.id] !== undefined && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">Reason for declining (optional)</label>
|
||||
<Textarea
|
||||
placeholder="e.g., Schedule conflict, too far to travel, etc."
|
||||
value={declineReason[proposal.id]}
|
||||
onChange={(e) => setDeclineReason({ ...declineReason, [proposal.id]: e.target.value })}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
{declineReason[proposal.id] === undefined ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => respondMutation.mutate({ proposalId: proposal.id, status: 'ACCEPTED' })}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700"
|
||||
disabled={respondMutation.isPending}
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Accept Shift
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setDeclineReason({ ...declineReason, [proposal.id]: '' })}
|
||||
variant="outline"
|
||||
className="flex-1 border-red-300 text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-2" />
|
||||
Decline
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => respondMutation.mutate({
|
||||
proposalId: proposal.id,
|
||||
status: 'DECLINED',
|
||||
reason: declineReason[proposal.id]
|
||||
})}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700"
|
||||
disabled={respondMutation.isPending}
|
||||
>
|
||||
Confirm Decline
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const newReasons = { ...declineReason };
|
||||
delete newReasons[proposal.id];
|
||||
setDeclineReason(newReasons);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Past Responses */}
|
||||
{pastProposals.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold text-slate-900">Past Responses</h2>
|
||||
{pastProposals.map((proposal) => (
|
||||
<Card key={proposal.id} className="border">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{proposal.event_name}</p>
|
||||
<p className="text-sm text-slate-500">{format(new Date(proposal.shift_date), 'MMM d, yyyy')}</p>
|
||||
</div>
|
||||
<Badge className={
|
||||
proposal.proposal_status === 'ACCEPTED'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}>
|
||||
{proposal.proposal_status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
frontend-web-free/src/pages/WorkforceCompliance.jsx
Normal file
169
frontend-web-free/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
frontend-web-free/src/pages/WorkforceDashboard.jsx
Normal file
178
frontend-web-free/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
frontend-web-free/src/pages/WorkforceEarnings.jsx
Normal file
111
frontend-web-free/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
frontend-web-free/src/pages/WorkforceProfile.jsx
Normal file
255
frontend-web-free/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>
|
||||
);
|
||||
}
|
||||
137
frontend-web-free/src/pages/WorkforceShifts.jsx
Normal file
137
frontend-web-free/src/pages/WorkforceShifts.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
|
||||
import React from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar, MapPin, Clock, DollarSign, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
// Safe date formatter
|
||||
const safeFormatDate = (dateString, formatStr) => {
|
||||
if (!dateString) return "Date TBD";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return "Date TBD";
|
||||
return format(date, formatStr);
|
||||
} catch {
|
||||
return "Date TBD";
|
||||
}
|
||||
};
|
||||
|
||||
export default function WorkforceShifts() {
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: events } = useQuery({
|
||||
queryKey: ['workforce-events'],
|
||||
queryFn: () => base44.entities.Event.list('-date'),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
// Filter events where this user is assigned
|
||||
const myShifts = events.filter(event =>
|
||||
event.assigned_staff?.some(staff => staff.staff_id === user?.id)
|
||||
);
|
||||
|
||||
const upcoming = myShifts.filter(e => new Date(e.date) >= new Date()).length;
|
||||
const confirmed = myShifts.filter(e =>
|
||||
e.assigned_staff?.find(s => s.staff_id === user?.id)?.confirmed
|
||||
).length;
|
||||
const pending = myShifts.filter(e =>
|
||||
!e.assigned_staff?.find(s => s.staff_id === user?.id)?.confirmed
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-[#1C323E]">My Shifts</h1>
|
||||
<p className="text-slate-500 mt-1">View and manage your upcoming shifts</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-6">
|
||||
<Calendar className="w-8 h-8 text-[#0A39DF] mb-2" />
|
||||
<p className="text-sm text-slate-500">Upcoming Shifts</p>
|
||||
<p className="text-3xl font-bold text-[#1C323E]">{upcoming}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-6">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-600 mb-2" />
|
||||
<p className="text-sm text-slate-500">Confirmed</p>
|
||||
<p className="text-3xl font-bold text-green-600">{confirmed}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-6">
|
||||
<AlertCircle className="w-8 h-8 text-yellow-600 mb-2" />
|
||||
<p className="text-sm text-slate-500">Pending Confirmation</p>
|
||||
<p className="text-3xl font-bold text-yellow-600">{pending}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Shifts List */}
|
||||
<div className="space-y-6">
|
||||
{myShifts.map((shift) => {
|
||||
const staffInfo = shift.assigned_staff?.find(s => s.staff_id === user?.id);
|
||||
return (
|
||||
<Card key={shift.id} className="border-slate-200">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<h3 className="text-xl font-bold text-[#1C323E]">{shift.event_name}</h3>
|
||||
<Badge className={staffInfo?.confirmed ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700"}>
|
||||
{staffInfo?.confirmed ? "Confirmed" : "Pending"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm text-slate-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{safeFormatDate(shift.date, 'PPP')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{shift.event_location || 'Location TBD'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
8:00 AM - 5:00 PM
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
<span className="font-semibold">$25/hour</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!staffInfo?.confirmed && (
|
||||
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
|
||||
Confirm Shift
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{shift.notes && (
|
||||
<div className="mt-3 p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-sm font-semibold text-slate-700 mb-1">Event Details:</p>
|
||||
<p className="text-sm text-slate-600">{shift.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2015
frontend-web-free/src/pages/api-docs-raw.jsx
Normal file
2015
frontend-web-free/src/pages/api-docs-raw.jsx
Normal file
File diff suppressed because it is too large
Load Diff
267
frontend-web-free/src/pages/index.jsx
Normal file
267
frontend-web-free/src/pages/index.jsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
|
||||
|
||||
// Auth components
|
||||
import ProtectedRoute from '@/components/auth/ProtectedRoute';
|
||||
import PublicRoute from '@/components/auth/PublicRoute';
|
||||
|
||||
// Layout and pages
|
||||
import Layout from "./Layout.jsx";
|
||||
import Home from "./Home";
|
||||
import Login from "./Login";
|
||||
import Register from "./Register";
|
||||
import Dashboard from "./Dashboard";
|
||||
import StaffDirectory from "./StaffDirectory";
|
||||
import AddStaff from "./AddStaff";
|
||||
import EditStaff from "./EditStaff";
|
||||
import Events from "./Events";
|
||||
import CreateEvent from "./CreateEvent";
|
||||
import EditEvent from "./EditEvent";
|
||||
import EventDetail from "./EventDetail";
|
||||
import Business from "./Business";
|
||||
import Invoices from "./Invoices";
|
||||
import Payroll from "./Payroll";
|
||||
import Certification from "./Certification";
|
||||
import Support from "./Support";
|
||||
import Reports from "./Reports";
|
||||
import Settings from "./Settings";
|
||||
import ActivityLog from "./ActivityLog";
|
||||
import AddBusiness from "./AddBusiness";
|
||||
import EditBusiness from "./EditBusiness";
|
||||
import ProcurementDashboard from "./ProcurementDashboard";
|
||||
import OperatorDashboard from "./OperatorDashboard";
|
||||
import VendorDashboard from "./VendorDashboard";
|
||||
import WorkforceDashboard from "./WorkforceDashboard";
|
||||
import Messages from "./Messages";
|
||||
import ClientDashboard from "./ClientDashboard";
|
||||
import Onboarding from "./Onboarding";
|
||||
import ClientOrders from "./ClientOrders";
|
||||
import ClientInvoices from "./ClientInvoices";
|
||||
import VendorOrders from "./VendorOrders";
|
||||
import VendorStaff from "./VendorStaff";
|
||||
import VendorInvoices from "./VendorInvoices";
|
||||
import VendorPerformance from "./VendorPerformance";
|
||||
import WorkforceShifts from "./WorkforceShifts";
|
||||
import WorkforceEarnings from "./WorkforceEarnings";
|
||||
import WorkforceProfile from "./WorkforceProfile";
|
||||
import UserManagement from "./UserManagement";
|
||||
import VendorRateCard from "./VendorRateCard";
|
||||
import Permissions from "./Permissions";
|
||||
import WorkforceCompliance from "./WorkforceCompliance";
|
||||
import Teams from "./Teams";
|
||||
import CreateTeam from "./CreateTeam";
|
||||
import TeamDetails from "./TeamDetails";
|
||||
import VendorManagement from "./VendorManagement";
|
||||
import PartnerManagement from "./PartnerManagement";
|
||||
import EnterpriseManagement from "./EnterpriseManagement";
|
||||
import VendorOnboarding from "./VendorOnboarding";
|
||||
import SectorManagement from "./SectorManagement";
|
||||
import AddEnterprise from "./AddEnterprise";
|
||||
import AddSector from "./AddSector";
|
||||
import AddPartner from "./AddPartner";
|
||||
import EditVendor from "./EditVendor";
|
||||
import SmartVendorOnboarding from "./SmartVendorOnboarding";
|
||||
import InviteVendor from "./InviteVendor";
|
||||
import VendorCompliance from "./VendorCompliance";
|
||||
import EditPartner from "./EditPartner";
|
||||
import EditSector from "./EditSector";
|
||||
import EditEnterprise from "./EditEnterprise";
|
||||
import VendorRates from "./VendorRates";
|
||||
import VendorDocumentReview from "./VendorDocumentReview";
|
||||
import VendorMarketplace from "./VendorMarketplace";
|
||||
import RapidOrder from "./RapidOrder";
|
||||
import SmartScheduler from "./SmartScheduler";
|
||||
import StaffOnboarding from "./StaffOnboarding";
|
||||
import NotificationSettings from "./NotificationSettings";
|
||||
import TaskBoard from "./TaskBoard";
|
||||
import InvoiceDetail from "./InvoiceDetail";
|
||||
import InvoiceEditor from "./InvoiceEditor";
|
||||
import Tutorials from "./Tutorials";
|
||||
import Schedule from "./Schedule";
|
||||
import StaffAvailability from "./StaffAvailability";
|
||||
import WorkerShiftProposals from "./WorkerShiftProposals";
|
||||
|
||||
const PAGES = {
|
||||
Dashboard,
|
||||
StaffDirectory,
|
||||
AddStaff,
|
||||
EditStaff,
|
||||
Events,
|
||||
CreateEvent,
|
||||
EditEvent,
|
||||
EventDetail,
|
||||
Business,
|
||||
Invoices,
|
||||
Payroll,
|
||||
Certification,
|
||||
Support,
|
||||
Reports,
|
||||
Settings,
|
||||
ActivityLog,
|
||||
AddBusiness,
|
||||
EditBusiness,
|
||||
ProcurementDashboard,
|
||||
OperatorDashboard,
|
||||
VendorDashboard,
|
||||
WorkforceDashboard,
|
||||
Messages,
|
||||
ClientDashboard,
|
||||
Onboarding,
|
||||
ClientOrders,
|
||||
ClientInvoices,
|
||||
VendorOrders,
|
||||
VendorStaff,
|
||||
VendorInvoices,
|
||||
VendorPerformance,
|
||||
WorkforceShifts,
|
||||
WorkforceEarnings,
|
||||
WorkforceProfile,
|
||||
UserManagement,
|
||||
Home,
|
||||
VendorRateCard,
|
||||
Permissions,
|
||||
WorkforceCompliance,
|
||||
Teams,
|
||||
CreateTeam,
|
||||
TeamDetails,
|
||||
VendorManagement,
|
||||
PartnerManagement,
|
||||
EnterpriseManagement,
|
||||
VendorOnboarding,
|
||||
SectorManagement,
|
||||
AddEnterprise,
|
||||
AddSector,
|
||||
AddPartner,
|
||||
EditVendor,
|
||||
SmartVendorOnboarding,
|
||||
InviteVendor,
|
||||
VendorCompliance,
|
||||
EditPartner,
|
||||
EditSector,
|
||||
EditEnterprise,
|
||||
VendorRates,
|
||||
VendorDocumentReview,
|
||||
VendorMarketplace,
|
||||
RapidOrder,
|
||||
SmartScheduler,
|
||||
StaffOnboarding,
|
||||
NotificationSettings,
|
||||
TaskBoard,
|
||||
InvoiceDetail,
|
||||
InvoiceEditor,
|
||||
Tutorials,
|
||||
Schedule,
|
||||
StaffAvailability,
|
||||
WorkerShiftProposals,
|
||||
};
|
||||
|
||||
function _getCurrentPage(url) {
|
||||
if (url.endsWith('/')) url = url.slice(0, -1);
|
||||
let last = url.split('/').pop();
|
||||
if (last.includes('?')) last = last.split('?')[0];
|
||||
const pageName = Object.keys(PAGES).find(p => p.toLowerCase() === last.toLowerCase());
|
||||
return pageName || 'Home'; // Default to Home
|
||||
}
|
||||
|
||||
|
||||
function AppRoutes() {
|
||||
const location = useLocation();
|
||||
const currentPage = _getCurrentPage(location.pathname);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
|
||||
<Route path="/register" element={<PublicRoute><Register /></PublicRoute>} />
|
||||
|
||||
{/* Private Routes */}
|
||||
<Route path="/*" element={
|
||||
<ProtectedRoute>
|
||||
<Layout currentPageName={currentPage}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/Dashboard" element={<Dashboard />} />
|
||||
<Route path="/StaffDirectory" element={<StaffDirectory />} />
|
||||
<Route path="/AddStaff" element={<AddStaff />} />
|
||||
<Route path="/EditStaff" element={<EditStaff />} />
|
||||
<Route path="/Events" element={<Events />} />
|
||||
<Route path="/CreateEvent" element={<CreateEvent />} />
|
||||
<Route path="/EditEvent" element={<EditEvent />} />
|
||||
<Route path="/EventDetail" element={<EventDetail />} />
|
||||
<Route path="/Business" element={<Business />} />
|
||||
<Route path="/Invoices" element={<Invoices />} />
|
||||
<Route path="/Payroll" element={<Payroll />} />
|
||||
<Route path="/Certification" element={<Certification />} />
|
||||
<Route path="/Support" element={<Support />} />
|
||||
<Route path="/Reports" element={<Reports />} />
|
||||
<Route path="/Settings" element={<Settings />} />
|
||||
<Route path="/ActivityLog" element={<ActivityLog />} />
|
||||
<Route path="/AddBusiness" element={<AddBusiness />} />
|
||||
<Route path="/EditBusiness" element={<EditBusiness />} />
|
||||
<Route path="/ProcurementDashboard" element={<ProcurementDashboard />} />
|
||||
<Route path="/OperatorDashboard" element={<OperatorDashboard />} />
|
||||
<Route path="/VendorDashboard" element={<VendorDashboard />} />
|
||||
<Route path="/WorkforceDashboard" element={<WorkforceDashboard />} />
|
||||
<Route path="/Messages" element={<Messages />} />
|
||||
<Route path="/ClientDashboard" element={<ClientDashboard />} />
|
||||
<Route path="/Onboarding" element={<Onboarding />} />
|
||||
<Route path="/ClientOrders" element={<ClientOrders />} />
|
||||
<Route path="/ClientInvoices" element={<ClientInvoices />} />
|
||||
<Route path="/VendorOrders" element={<VendorOrders />} />
|
||||
<Route path="/VendorStaff" element={<VendorStaff />} />
|
||||
<Route path="/VendorInvoices" element={<VendorInvoices />} />
|
||||
<Route path="/VendorPerformance" element={<VendorPerformance />} />
|
||||
<Route path="/WorkforceShifts" element={<WorkforceShifts />} />
|
||||
<Route path="/WorkforceEarnings" element={<WorkforceEarnings />} />
|
||||
<Route path="/WorkforceProfile" element={<WorkforceProfile />} />
|
||||
<Route path="/UserManagement" element={<UserManagement />} />
|
||||
<Route path="/Home" element={<Home />} />
|
||||
<Route path="/VendorRateCard" element={<VendorRateCard />} />
|
||||
<Route path="/Permissions" element={<Permissions />} />
|
||||
<Route path="/WorkforceCompliance" element={<WorkforceCompliance />} />
|
||||
<Route path="/Teams" element={<Teams />} />
|
||||
<Route path="/CreateTeam" element={<CreateTeam />} />
|
||||
<Route path="/TeamDetails" element={<TeamDetails />} />
|
||||
<Route path="/VendorManagement" element={<VendorManagement />} />
|
||||
<Route path="/PartnerManagement" element={<PartnerManagement />} />
|
||||
<Route path="/EnterpriseManagement" element={<EnterpriseManagement />} />
|
||||
<Route path="/VendorOnboarding" element={<VendorOnboarding />} />
|
||||
<Route path="/SectorManagement" element={<SectorManagement />} />
|
||||
<Route path="/AddEnterprise" element={<AddEnterprise />} />
|
||||
<Route path="/AddSector" element={<AddSector />} />
|
||||
<Route path="/AddPartner" element={<AddPartner />} />
|
||||
<Route path="/EditVendor" element={<EditVendor />} />
|
||||
<Route path="/SmartVendorOnboarding" element={<SmartVendorOnboarding />} />
|
||||
<Route path="/InviteVendor" element={<InviteVendor />} />
|
||||
<Route path="/VendorCompliance" element={<VendorCompliance />} />
|
||||
<Route path="/EditPartner" element={<EditPartner />} />
|
||||
<Route path="/EditSector" element={<EditSector />} />
|
||||
<Route path="/EditEnterprise" element={<EditEnterprise />} />
|
||||
<Route path="/VendorRates" element={<VendorRates />} />
|
||||
<Route path="/VendorDocumentReview" element={<VendorDocumentReview />} />
|
||||
<Route path="/VendorMarketplace" element={<VendorMarketplace />} />
|
||||
<Route path="/RapidOrder" element={<RapidOrder />} />
|
||||
<Route path="/SmartScheduler" element={<SmartScheduler />} />
|
||||
<Route path="/StaffOnboarding" element={<StaffOnboarding />} />
|
||||
<Route path="/NotificationSettings" element={<NotificationSettings />} />
|
||||
<Route path="/TaskBoard" element={<TaskBoard />} />
|
||||
<Route path="/InvoiceDetail" element={<InvoiceDetail />} />
|
||||
<Route path="/InvoiceEditor" element={<InvoiceEditor />} />
|
||||
<Route path="/Tutorials" element={<Tutorials />} />
|
||||
<Route path="/Schedule" element={<Schedule />} />
|
||||
<Route path="/StaffAvailability" element={<StaffAvailability />} />
|
||||
<Route path="/WorkerShiftProposals" element={<WorkerShiftProposals />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Pages() {
|
||||
return (
|
||||
<Router>
|
||||
<AppRoutes />
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user