427 lines
18 KiB
JavaScript
427 lines
18 KiB
JavaScript
|
|
import React, { useMemo, useState } from "react";
|
|
import { base44 } from "@/api/base44Client";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
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 { Plus, Minus, Trash2, Search, DollarSign, TrendingUp, Check } from "lucide-react";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
} from "@/components/ui/command";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const DEPARTMENTS = [
|
|
"Accounting", "Operations", "Sales", "HR", "Finance",
|
|
"IT", "Marketing", "Customer Service", "Logistics"
|
|
];
|
|
|
|
const UNIFORMS = ["Type 1", "Type 2", "Type 3", "Casual", "Formal"];
|
|
|
|
const TIME_OPTIONS = [];
|
|
for (let h = 1; h <= 12; h++) {
|
|
for (let m of ['00', '30']) {
|
|
TIME_OPTIONS.push(`${h.toString().padStart(2, '0')}:${m} AM`);
|
|
}
|
|
}
|
|
for (let h = 1; h <= 12; h++) {
|
|
for (let m of ['00', '30']) {
|
|
TIME_OPTIONS.push(`${h.toString().padStart(2, '0')}:${m} PM`);
|
|
}
|
|
}
|
|
|
|
export default function ShiftRoleCard({ role, roleIndex, onRoleChange, onDelete, canDelete, selectedVendor }) {
|
|
const [roleSearchOpen, setRoleSearchOpen] = useState(false);
|
|
|
|
// Get current user to check role
|
|
const { data: user } = useQuery({
|
|
queryKey: ['current-user-shift-role'],
|
|
queryFn: () => base44.auth.me(),
|
|
});
|
|
|
|
const userRole = user?.user_role || user?.role;
|
|
const isClient = userRole === "client";
|
|
|
|
// Get client's vendor relationships if client
|
|
const { data: clientVendors = [] } = useQuery({
|
|
queryKey: ['client-vendors-for-roles', user?.id],
|
|
queryFn: async () => {
|
|
if (!isClient) return [];
|
|
|
|
const allEvents = await base44.entities.Event.list();
|
|
const clientEvents = allEvents.filter(e =>
|
|
e.client_email === user?.email ||
|
|
e.business_name === user?.company_name ||
|
|
e.created_by === user?.email
|
|
);
|
|
|
|
const vendorNames = new Set();
|
|
clientEvents.forEach(event => {
|
|
if (event.shifts && Array.isArray(event.shifts)) {
|
|
event.shifts.forEach(shift => {
|
|
if (shift.roles && Array.isArray(shift.roles)) {
|
|
shift.roles.forEach(role => {
|
|
if (role.vendor_name) vendorNames.add(role.vendor_name);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
return Array.from(vendorNames);
|
|
},
|
|
enabled: isClient && !!user,
|
|
initialData: [],
|
|
});
|
|
|
|
// Fetch all vendor rates
|
|
const { data: allRates = [], isLoading } = useQuery({
|
|
queryKey: ['vendor-rates-for-event'],
|
|
queryFn: () => base44.entities.VendorRate.list(),
|
|
initialData: [],
|
|
});
|
|
|
|
// Filter rates by selected vendor AND client access
|
|
const availableRates = useMemo(() => {
|
|
if (!allRates || !Array.isArray(allRates)) return [];
|
|
|
|
let filtered = allRates.filter(r => r && r.is_active);
|
|
|
|
// If client, only show rates from vendors they work with
|
|
if (isClient) {
|
|
filtered = filtered.filter(r => {
|
|
const hasVendorRelationship = clientVendors.length === 0 || clientVendors.includes(r.vendor_name);
|
|
const isVisibleToClient =
|
|
r.client_visibility === 'all' ||
|
|
(r.client_visibility === 'specific' &&
|
|
r.available_to_clients &&
|
|
Array.isArray(r.available_to_clients) && // Ensure it's an array before using includes
|
|
r.available_to_clients.includes(user?.id));
|
|
|
|
return hasVendorRelationship && isVisibleToClient;
|
|
});
|
|
}
|
|
|
|
// Filter by selected vendor if provided
|
|
if (selectedVendor) {
|
|
filtered = filtered.filter(r => r.vendor_name === selectedVendor);
|
|
}
|
|
|
|
return filtered;
|
|
}, [allRates, selectedVendor, isClient, clientVendors, user?.id]);
|
|
|
|
// Group rates by category
|
|
const ratesByCategory = useMemo(() => {
|
|
if (!availableRates || !Array.isArray(availableRates)) return {};
|
|
return availableRates.reduce((acc, rate) => {
|
|
if (!rate || !rate.category) return acc;
|
|
if (!acc[rate.category]) acc[rate.category] = [];
|
|
acc[rate.category].push(rate);
|
|
return acc;
|
|
}, {});
|
|
}, [availableRates]);
|
|
|
|
// Handle role selection from vendor rates
|
|
const handleRoleSelect = (rate) => {
|
|
if (!rate) return;
|
|
onRoleChange('role', rate.role_name || '');
|
|
onRoleChange('department', rate.category || '');
|
|
onRoleChange('cost_per_hour', rate.client_rate || 0);
|
|
onRoleChange('vendor_name', rate.vendor_name || '');
|
|
onRoleChange('vendor_id', rate.vendor_id || '');
|
|
setRoleSearchOpen(false);
|
|
};
|
|
|
|
// Get selected rate details
|
|
const selectedRate = availableRates.find(r => r && r.role_name === role.role);
|
|
|
|
return (
|
|
<Card className="border border-slate-200 bg-white hover:border-[#0A39DF] transition-all">
|
|
<CardContent className="p-4">
|
|
<div className="grid grid-cols-12 gap-4 items-start">
|
|
{/* Row Number */}
|
|
<div className="col-span-12 md:col-span-1 flex items-center justify-center">
|
|
<div className="w-8 h-8 bg-slate-700 rounded-full flex items-center justify-center text-white font-bold text-sm">
|
|
{roleIndex + 1}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Role Selection with Vendor Rates - SEARCHABLE */}
|
|
<div className="col-span-12 md:col-span-3 space-y-2">
|
|
<div>
|
|
<Label className="text-xs text-slate-600 mb-1">Service / Role</Label>
|
|
<Popover open={roleSearchOpen} onOpenChange={setRoleSearchOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
className={cn(
|
|
"w-full justify-between text-sm h-auto py-2 bg-white hover:bg-slate-50",
|
|
!role.role && "text-slate-500"
|
|
)}
|
|
>
|
|
<div className="flex flex-col items-start gap-1 text-left">
|
|
{role.role ? (
|
|
<>
|
|
<span className="font-semibold text-slate-900">{role.role}</span>
|
|
{selectedRate && (
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="text-[10px] bg-blue-50 text-blue-700 border-blue-200">
|
|
{selectedRate.category}
|
|
</Badge>
|
|
<span className="text-xs text-green-600 font-bold">${selectedRate.client_rate}/hr</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<span>Select service...</span>
|
|
)}
|
|
</div>
|
|
<Search className="w-4 h-4 ml-2 text-slate-400 flex-shrink-0" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[400px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="Search services..." className="h-9" />
|
|
<CommandEmpty>
|
|
<div className="p-4 text-center text-sm text-slate-500">
|
|
<p>No services found.</p>
|
|
{selectedVendor && (
|
|
<p className="mt-2 text-xs">Contact {selectedVendor} to add services.</p>
|
|
)}
|
|
{!selectedVendor && (
|
|
<p className="mt-2 text-xs">Select a vendor first to see available services.</p>
|
|
)}
|
|
</div>
|
|
</CommandEmpty>
|
|
<div className="max-h-[300px] overflow-y-auto">
|
|
{Object.entries(ratesByCategory || {}).map(([category, rates]) => (
|
|
<CommandGroup key={category} heading={category} className="text-slate-700">
|
|
{Array.isArray(rates) && rates.map((rate) => (
|
|
<CommandItem
|
|
key={rate.id}
|
|
value={`${rate.role_name} ${rate.vendor_name} ${rate.category}`}
|
|
onSelect={() => handleRoleSelect(rate)}
|
|
className="flex items-center justify-between py-3 cursor-pointer"
|
|
>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<p className="font-semibold text-sm text-slate-900">{rate.role_name}</p>
|
|
{role.role === rate.role_name && (
|
|
<Check className="w-4 h-4 text-[#0A39DF]" />
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<Badge variant="outline" className="text-[10px] bg-slate-50">
|
|
{rate.vendor_name}
|
|
</Badge>
|
|
{rate.pricing_status === 'optimal' && (
|
|
<Badge className="bg-green-100 text-green-700 text-[10px] border-0">
|
|
Optimal Price
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 ml-4">
|
|
<div className="text-right">
|
|
<p className="text-lg font-bold text-[#0A39DF]">${rate.client_rate}</p>
|
|
<p className="text-[10px] text-slate-500">per hour</p>
|
|
</div>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
))}
|
|
</div>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{/* Department (Auto-filled from category) */}
|
|
<div>
|
|
<Label className="text-xs text-slate-600 mb-1">Department</Label>
|
|
<Select value={role.department || ""} onValueChange={(value) => onRoleChange('department', value)}>
|
|
<SelectTrigger className="h-9 text-sm bg-white">
|
|
<SelectValue placeholder="Department" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DEPARTMENTS.map(dept => (
|
|
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Count */}
|
|
<div className="col-span-6 md:col-span-2">
|
|
<Label className="text-xs text-slate-600 mb-1 block">Count</Label>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-9 w-9 bg-white hover:bg-slate-50"
|
|
onClick={() => onRoleChange('count', Math.max(1, (role.count || 1) - 1))}
|
|
>
|
|
<Minus className="w-3 h-3" />
|
|
</Button>
|
|
<Input
|
|
type="number"
|
|
value={role.count || 1}
|
|
onChange={(e) => onRoleChange('count', parseInt(e.target.value) || 1)}
|
|
className="w-14 h-9 text-center p-0 text-sm bg-white"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-9 w-9 bg-white hover:bg-slate-50"
|
|
onClick={() => onRoleChange('count', (role.count || 1) + 1)}
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Time */}
|
|
<div className="col-span-6 md:col-span-3 space-y-2">
|
|
<div>
|
|
<Label className="text-xs text-slate-600 mb-1">Start Time</Label>
|
|
<Select value={role.start_time || "12:00 PM"} onValueChange={(value) => onRoleChange('start_time', value)}>
|
|
<SelectTrigger className="h-9 text-sm bg-white">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="max-h-48">
|
|
{TIME_OPTIONS.map(time => (
|
|
<SelectItem key={time} value={time}>{time}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs text-slate-600 mb-1">End Time</Label>
|
|
<Select value={role.end_time || "05:00 PM"} onValueChange={(value) => onRoleChange('end_time', value)}>
|
|
<SelectTrigger className="h-9 text-sm bg-white">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="max-h-48">
|
|
{TIME_OPTIONS.map(time => (
|
|
<SelectItem key={time} value={time}>{time}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hours Badge */}
|
|
<div className="col-span-3 md:col-span-1 flex flex-col items-center justify-center">
|
|
<Label className="text-xs text-slate-600 mb-1">Hours</Label>
|
|
<div className="bg-[#0A39DF] text-white rounded-full w-10 h-10 flex items-center justify-center font-bold text-sm">
|
|
{role.hours || 0}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Uniform & Break */}
|
|
<div className="col-span-9 md:col-span-2 space-y-2">
|
|
<div>
|
|
<Label className="text-xs text-slate-600 mb-1">Uniform</Label>
|
|
<Select value={role.uniform || "Type 1"} onValueChange={(value) => onRoleChange('uniform', value)}>
|
|
<SelectTrigger className="h-9 text-sm bg-white">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{UNIFORMS.map(u => (
|
|
<SelectItem key={u} value={u}>{u}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs text-slate-600 mb-1">Break (min)</Label>
|
|
<Input
|
|
type="number"
|
|
value={role.break_minutes || 30}
|
|
onChange={(e) => onRoleChange('break_minutes', parseInt(e.target.value) || 0)}
|
|
className="h-9 text-center text-sm bg-white"
|
|
placeholder="30"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cost & Value */}
|
|
<div className="col-span-12 md:col-span-3 flex items-end justify-between gap-4 pt-4 md:pt-0 border-t md:border-t-0 border-slate-200">
|
|
<div className="flex-1">
|
|
<Label className="text-xs text-slate-600 mb-1 block">Rate/hr</Label>
|
|
<div className="relative">
|
|
<DollarSign className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-slate-400" />
|
|
<Input
|
|
type="number"
|
|
value={role.cost_per_hour || 0}
|
|
onChange={(e) => onRoleChange('cost_per_hour', parseFloat(e.target.value) || 0)}
|
|
className="h-9 text-sm pl-6 bg-white"
|
|
placeholder="0.00"
|
|
disabled={!!selectedRate}
|
|
/>
|
|
</div>
|
|
{selectedRate && (
|
|
<p className="text-[10px] text-green-600 mt-1">From vendor rate card</p>
|
|
)}
|
|
</div>
|
|
<div className="flex-1">
|
|
<Label className="text-xs text-slate-600 mb-1 block">Total</Label>
|
|
<div className="h-9 flex items-center justify-end font-bold text-[#0A39DF] text-lg">
|
|
${(role.total_value || 0).toFixed(2)}
|
|
</div>
|
|
</div>
|
|
{canDelete && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-9 w-9 text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
onClick={onDelete}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pricing Info Bar */}
|
|
{selectedRate && (
|
|
<div className="mt-3 pt-3 border-t border-slate-200 flex items-center justify-between text-xs bg-slate-50 -mx-4 -mb-4 px-4 py-3 rounded-b-lg">
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-slate-500">
|
|
Employee Wage: <span className="font-semibold text-slate-700">${selectedRate.employee_wage || 0}/hr</span>
|
|
</span>
|
|
<span className="text-slate-500">
|
|
Markup: <span className="font-semibold text-blue-600">{selectedRate.markup_percentage || 0}%</span>
|
|
</span>
|
|
<span className="text-slate-500">
|
|
VA Fee: <span className="font-semibold text-purple-600">{selectedRate.vendor_fee_percentage || 0}%</span>
|
|
</span>
|
|
</div>
|
|
<Badge variant="outline" className="text-[10px] bg-green-50 text-green-700 border-green-200">
|
|
Transparent Pricing
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|