new version frontend-webpage

This commit is contained in:
José Salazar
2025-11-21 09:13:05 -05:00
parent 23dfba35cc
commit de1cc96ba0
56 changed files with 7736 additions and 3367 deletions

View File

@@ -25,17 +25,23 @@ const COLORS = ['#0A39DF', '#6366f1', '#8b5cf6', '#a855f7', '#c026d3', '#d946ef'
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;
}
};

View File

@@ -1,4 +1,3 @@
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -24,10 +23,11 @@ import {
} from "@/components/ui/tabs"; // New import
import {
Search, Calendar, MapPin, Users, Eye, Edit, X, Trash2, FileText, // Edit instead of Edit2
Clock, DollarSign, Package, CheckCircle, AlertTriangle, Grid, List, Zap, Plus
Clock, DollarSign, Package, CheckCircle, AlertTriangle, Grid, List, Zap, Plus, Building2, Bell, Edit3
} 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;
@@ -94,6 +94,8 @@ export default function ClientOrders() {
const [statusFilter, setStatusFilter] = useState("all"); // Updated values for Tabs
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 { data: user } = useQuery({
queryKey: ['current-user-client-orders'],
@@ -180,6 +182,11 @@ export default function ClientOrders() {
setCancelDialogOpen(true); // Updated
};
const handleViewOrder = (order) => {
setSelectedOrder(order);
setViewOrderModal(true);
};
const confirmCancel = () => {
if (orderToCancel) { // Updated
cancelOrderMutation.mutate(orderToCancel.id); // Updated
@@ -332,118 +339,115 @@ export default function ClientOrders() {
<CardContent className="p-0"> {/* CardContent padding updated */}
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50"> {/* TableRow class updated */}
<TableHead className="font-semibold text-slate-700">Order</TableHead> {/* Updated */}
<TableHead className="font-semibold text-slate-700">Date</TableHead> {/* Updated */}
<TableHead className="font-semibold text-slate-700">Location</TableHead> {/* Updated */}
<TableHead className="font-semibold text-slate-700">Time</TableHead> {/* Updated */}
<TableHead className="font-semibold text-slate-700">Status</TableHead> {/* Updated */}
<TableHead className="font-semibold text-slate-700 text-center">Staff</TableHead> {/* Updated */}
<TableHead className="font-semibold text-slate-700 text-center">Invoice</TableHead> {/* Updated */}
<TableHead className="font-semibold text-slate-700 text-center">Actions</TableHead> {/* Updated */}
<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 ? ( // Using filteredOrders
{filteredOrders.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-slate-500"> {/* Colspan updated */}
<Package className="w-12 h-12 mx-auto mb-3 text-slate-300" /> {/* Icon updated */}
<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) => { // Using filteredOrders, renamed event to order
const assignment = getAssignmentStatus(order);
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);
const invoiceReady = order.status === "Completed";
// const eventDate = safeParseDate(order.date); // Not directly used here, safeFormatDate handles it.
return (
<TableRow key={order.id} className="hover:bg-slate-50">
<TableCell> {/* Order cell */}
<div>
<p className="font-semibold text-slate-900">{order.event_name}</p>
<p className="text-xs text-slate-500">{order.business_name || ""}</p>
<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> {/* Date cell */}
<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-semibold text-slate-900">
{safeFormatDate(order.date, 'MMM dd, yyyy')}
</p>
<p className="text-xs text-slate-500">
{safeFormatDate(order.date, 'EEEE')}
<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> {/* Location cell */}
<div className="flex items-center gap-1.5 text-sm text-slate-600">
<MapPin className="w-3.5 h-3.5 text-slate-400" />
{order.hub || order.event_location || "—"}
</div>
</TableCell>
<TableCell> {/* Time cell */}
<div className="flex items-center gap-1 text-sm text-slate-600">
<Clock className="w-3.5 h-3.5 text-slate-400" />
{startTime} - {endTime}
</div>
</TableCell>
<TableCell> {/* Status cell */}
<TableCell>
{getStatusBadge(order)}
</TableCell>
<TableCell className="text-center"> {/* Staff cell */}
<TableCell>
<span className="text-lg font-bold text-slate-900">{requestedCount}</span>
</TableCell>
<TableCell>
<div className="flex flex-col items-center gap-1">
<Badge className={assignment.badgeClass}>
{assignment.assigned} / {assignment.requested}
</Badge>
<span className="text-[10px] text-slate-500 font-medium">
{assignment.percentage}%
</span>
<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 className="text-center"> {/* Invoice cell */}
<div className="flex items-center justify-center">
<Button // Changed from a div to a Button for better accessibility
variant="ghost"
size="icon"
onClick={() => invoiceReady && navigate(createPageUrl('Invoices'))}
className={`w-10 h-10 rounded-full flex items-center justify-center ${invoiceReady ? 'bg-blue-100' : 'bg-slate-100'} ${invoiceReady ? 'cursor-pointer hover:bg-blue-200' : 'cursor-not-allowed opacity-50'}`}
disabled={!invoiceReady}
title={invoiceReady ? "View Invoice" : "Invoice not available"}
>
<FileText className={`w-5 h-5 ${invoiceReady ? 'text-blue-600' : 'text-slate-400'}`} />
</Button>
</div>
<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> {/* Actions cell */}
<div className="flex items-center justify-center gap-1">
<TableCell>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EventDetail?id=${order.id}`))}
className="hover:bg-slate-100"
title="View details"
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="hover:bg-slate-100"
title="Edit order"
className="h-8 w-8 p-0"
title="Edit"
>
<Edit className="w-4 h-4" /> {/* Changed from Edit2 */}
<Edit3 className="w-4 h-4" />
</Button>
)}
{canCancelOrder(order) && (
<Button
variant="ghost"
size="icon"
onClick={() => handleCancelOrder(order)} // Updated
className="hover:bg-red-50 hover:text-red-600"
title="Cancel order"
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>
@@ -460,6 +464,13 @@ export default function ClientOrders() {
</Card>
</div>
<OrderDetailModal
open={viewOrderModal}
onClose={() => setViewOrderModal(false)}
order={selectedOrder}
onCancel={handleCancelOrder}
/>
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}> {/* Updated open and onOpenChange */}
<DialogContent>
<DialogHeader>
@@ -505,4 +516,4 @@ export default function ClientOrders() {
</Dialog>
</div>
);
}
}

View File

@@ -4,6 +4,7 @@ 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";
@@ -16,6 +17,7 @@ export default function CreateEvent() {
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'],
@@ -48,15 +50,56 @@ export default function CreateEvent() {
},
});
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(eventData, allEvents);
const conflicts = detectAllConflicts(eventDataWithRequested, allEvents);
if (conflicts.length > 0) {
setPendingEvent({ ...eventData, detected_conflicts: conflicts });
setPendingEvent({ ...eventDataWithRequested, detected_conflicts: conflicts });
setShowConflictWarning(true);
} else {
createEventMutation.mutate(eventData);
createEventMutation.mutate(eventDataWithRequested);
}
};
@@ -137,6 +180,7 @@ export default function CreateEvent() {
<EventFormWizard
event={null}
onSubmit={handleSubmit}
onRapidSubmit={handleRapidSubmit}
isSubmitting={createEventMutation.isPending}
currentUser={currentUser}
onCancel={() => navigate(createPageUrl("ClientDashboard"))}

View File

@@ -1,17 +1,29 @@
import React from "react";
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 EventForm from "@/components/events/EventForm";
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'],
@@ -19,7 +31,19 @@ export default function EditEvent() {
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),
@@ -30,7 +54,97 @@ export default function EditEvent() {
});
const handleSubmit = (eventData) => {
updateEventMutation.mutate({ id: eventId, data: 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) {
@@ -54,7 +168,7 @@ export default function EditEvent() {
return (
<div className="p-4 md:p-8">
<div className="max-w-4xl mx-auto">
<div className="max-w-7xl mx-auto">
<div className="mb-8">
<Button
variant="ghost"
@@ -68,10 +182,31 @@ export default function EditEvent() {
<p className="text-slate-600">Update information for {event.event_name}</p>
</div>
<EventForm
{showReductionAlert && pendingUpdate && (
<div className="mb-6">
<OrderReductionAlert
originalRequested={originalRequested}
newRequested={pendingUpdate.requested}
currentAssigned={event.assigned_staff?.length || 0}
onAutoUnassign={handleAutoUnassign}
onManualUnassign={handleManualUnassign}
lowReliabilityStaff={(event.assigned_staff || []).map(assigned => {
const staffData = allStaff.find(s => s.id === assigned.staff_id);
return {
name: assigned.staff_name,
reliability: staffData?.reliability_score || 50
};
}).sort((a, b) => a.reliability - b.reliability)}
/>
</div>
)}
<EventFormWizard
event={event}
onSubmit={handleSubmit}
isSubmitting={updateEventMutation.isPending}
currentUser={user}
onCancel={() => navigate(createPageUrl("Events"))}
/>
</div>
</div>

View File

@@ -17,6 +17,7 @@ import {
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";
@@ -35,6 +36,7 @@ export default function EventDetail() {
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");
@@ -58,11 +60,21 @@ export default function EventDetail() {
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",
});
setCancelDialog(false);
setShowCancellationFeeModal(false);
navigate(createPageUrl("ClientOrders"));
},
onError: () => {
@@ -74,6 +86,14 @@ export default function EventDetail() {
},
});
const handleCancelClick = () => {
setShowCancellationFeeModal(true);
};
const handleConfirmCancellation = () => {
cancelOrderMutation.mutate();
};
const handleNotifyStaff = async () => {
const assignedStaff = event?.assigned_staff || [];
@@ -171,11 +191,11 @@ export default function EventDetail() {
)}
{canCancelOrder() && (
<button
onClick={() => setCancelDialog(true)}
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
Cancel Order
</button>
)}
{!isClient && event.assigned_staff?.length > 0 && (
@@ -269,7 +289,7 @@ export default function EventDetail() {
<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} />
<ShiftCard key={idx} shift={shift} event={event} currentUser={user} />
))
) : (
<Card className="bg-white border border-slate-200">
@@ -316,48 +336,14 @@ export default function EventDetail() {
</DialogContent>
</Dialog>
{/* Cancel Order Dialog */}
<Dialog open={cancelDialog} onOpenChange={setCancelDialog}>
<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 and the vendor will be notified immediately.
</DialogDescription>
</DialogHeader>
<div className="bg-slate-50 rounded-lg p-4 space-y-2">
<p className="font-bold text-slate-900">{event.event_name}</p>
<div className="flex items-center gap-2 text-sm text-slate-600">
<Calendar className="w-4 h-4" />
{safeFormatDate(event.date)}
</div>
<div className="flex items-center gap-2 text-sm text-slate-600">
<MapPin className="w-4 h-4" />
{event.hub || event.event_location}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setCancelDialog(false)}
>
Keep Order
</Button>
<Button
variant="destructive"
onClick={() => cancelOrderMutation.mutate()}
disabled={cancelOrderMutation.isPending}
>
{cancelOrderMutation.isPending ? "Canceling..." : "Yes, Cancel Order"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Cancellation Fee Modal */}
<CancellationFeeModal
open={showCancellationFeeModal}
onClose={() => setShowCancellationFeeModal(false)}
onConfirm={handleConfirmCancellation}
event={event}
isSubmitting={cancelOrderMutation.isPending}
/>
</div>
);
}

View File

@@ -1,4 +1,3 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
@@ -444,18 +443,32 @@ export default function Events() {
</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 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>
@@ -688,4 +701,4 @@ export default function Events() {
<SmartAssignModal open={assignModal.open} onClose={() => setAssignModal({ open: false, event: null, shift: null, role: null })} event={assignModal.event} shift={assignModal.shift} role={assignModal.role} />
</div>
);
}
}

View File

@@ -0,0 +1,68 @@
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 { 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 invoice = invoices.find(inv => inv.id === invoiceId);
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>
<InvoiceDetailView invoice={invoice} userRole={userRole} />
</>
);
}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import React from "react";
import { Link, useLocation } from "react-router-dom";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
@@ -9,7 +9,7 @@ import {
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
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap, ArrowLeft
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -37,7 +37,7 @@ import { Toaster } from "@/components/ui/toaster";
// Navigation items for each role
const roleNavigationMap = {
admin: [
{ title: "Dashboard", url: createPageUrl("Dashboard"), icon: LayoutDashboard },
{ title: "Home", url: createPageUrl("Dashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
@@ -58,7 +58,7 @@ const roleNavigationMap = {
{ title: "Activity Log", url: createPageUrl("ActivityLog"), icon: Activity },
],
procurement: [
{ title: "Dashboard", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
{ 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 },
@@ -73,7 +73,7 @@ const roleNavigationMap = {
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
],
operator: [
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ 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 },
@@ -87,7 +87,7 @@ const roleNavigationMap = {
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
],
sector: [
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ 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 },
@@ -100,7 +100,7 @@ const roleNavigationMap = {
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
],
client: [
{ title: "Dashboard", url: createPageUrl("ClientDashboard"), icon: LayoutDashboard },
{ 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 },
@@ -113,7 +113,7 @@ const roleNavigationMap = {
{ title: "Support", url: createPageUrl("Support"), icon: HelpCircle },
],
vendor: [
{ title: "Dashboard", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
{ 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 },
@@ -131,7 +131,7 @@ const roleNavigationMap = {
{ title: "Performance", url: createPageUrl("VendorPerformance"), icon: TrendingUp },
],
workforce: [
{ title: "Dashboard", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
{ title: "Home", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
@@ -241,6 +241,7 @@ function NavigationMenu({ location, userRole, closeSheet }) {
export default function Layout({ children }) {
const location = useLocation();
const navigate = useNavigate();
const [showNotifications, setShowNotifications] = React.useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
@@ -323,6 +324,16 @@ export default function Layout({ children }) {
<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">

View File

@@ -36,7 +36,7 @@ export default function Onboarding() {
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(' ');
@@ -44,10 +44,14 @@ export default function Onboarding() {
...prev,
email: foundInvite.email,
first_name: nameParts[0] || "",
last_name: nameParts.slice(1).join(' ') || ""
last_name: nameParts.slice(1).join(' ') || "",
phone: foundInvite.phone || "",
department: foundInvite.department || "",
hub: foundInvite.hub || "",
title: foundInvite.title || ""
}));
}
return foundInvite;
},
enabled: !!inviteCode,
@@ -65,6 +69,40 @@ export default function Onboarding() {
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) {
@@ -233,8 +271,14 @@ export default function Onboarding() {
<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-gradient-to-r from-blue-500 to-indigo-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>
@@ -313,6 +357,7 @@ export default function Onboarding() {
placeholder="+1 (555) 123-4567"
className="mt-2"
/>
<p className="text-xs text-slate-500 mt-1">You can edit this if needed</p>
</div>
<Button
@@ -353,23 +398,25 @@ export default function Onboarding() {
<SelectValue placeholder="Select department" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Operations">Operations</SelectItem>
<SelectItem value="Sales">Sales</SelectItem>
<SelectItem value="HR">HR</SelectItem>
<SelectItem value="Finance">Finance</SelectItem>
<SelectItem value="IT">IT</SelectItem>
<SelectItem value="Marketing">Marketing</SelectItem>
<SelectItem value="Customer Service">Customer Service</SelectItem>
<SelectItem value="Logistics">Logistics</SelectItem>
<SelectItem value="Management">Management</SelectItem>
<SelectItem value="Other">Other</SelectItem>
{availableDepartments.length > 0 ? (
availableDepartments.map((dept) => (
<SelectItem key={dept} value={dept}>
{dept}
</SelectItem>
))
) : (
<SelectItem value="Operations">Operations</SelectItem>
)}
</SelectContent>
</Select>
{formData.department && (
<p className="text-xs text-slate-500 mt-1">✓ Pre-filled from your invitation</p>
)}
</div>
{hubs.length > 0 && (
<div>
<Label htmlFor="hub">Hub Location (Optional)</Label>
<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" />
@@ -383,6 +430,9 @@ export default function Onboarding() {
))}
</SelectContent>
</Select>
{formData.hub && (
<p className="text-xs text-blue-600 font-semibold mt-1">📍 You're joining {formData.hub}!</p>
)}
</div>
)}

View File

@@ -130,7 +130,8 @@ Return a concise summary.`,
const primaryLocation = businesses[0]?.business_name || "Primary Location";
// Ensure count is properly set - default to 1 if not detected
const staffCount = parsed.count && parsed.count > 0 ? parsed.count : 1;
// 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();
@@ -221,15 +222,17 @@ Return a concise summary.`,
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: ${detectedOrder.count} ${detectedOrder.role}${detectedOrder.count > 1 ? 's' : ''}`,
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: Number(detectedOrder.count), // Ensure it's a number
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}`,
@@ -238,7 +241,7 @@ Return a concise summary.`,
location: detectedOrder.location,
roles: [{
role: detectedOrder.role,
count: Number(detectedOrder.count), // Ensure it's a number
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
}]

View File

@@ -11,7 +11,15 @@ 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 } from "lucide-react";
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";
@@ -32,6 +40,15 @@ export default function TaskBoard() {
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'],
@@ -57,7 +74,30 @@ export default function TaskBoard() {
});
const userTeam = teams.find(t => t.owner_id === user?.id) || teams[0];
const teamTasks = tasks.filter(t => t.team_id === userTeam?.id);
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');
@@ -66,12 +106,30 @@ export default function TaskBoard() {
// 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: teamTasks.filter(t => t.status === 'pending').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
in_progress: teamTasks.filter(t => t.status === 'in_progress').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
on_hold: teamTasks.filter(t => t.status === 'on_hold').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
completed: teamTasks.filter(t => t.status === 'completed').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
}), [teamTasks]);
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;
@@ -158,6 +216,130 @@ export default function TaskBoard() {
<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>
@@ -205,8 +387,8 @@ export default function TaskBoard() {
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" className="border-slate-300">
<Link2 className="w-4 h-4 mr-2" />
<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
@@ -214,10 +396,10 @@ export default function TaskBoard() {
setSelectedStatus("pending");
setCreateDialog(true);
}}
className="bg-[#0A39DF] hover:bg-blue-700"
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-4 h-4 mr-2" />
Create List
<Plus className="w-5 h-5" />
Create Task
</Button>
</div>
</div>
@@ -256,6 +438,8 @@ export default function TaskBoard() {
task={task}
provided={provided}
onClick={() => setSelectedTask(task)}
itemHeight={itemHeight}
conditionalColoring={conditionalColoring}
/>
)}
</Draggable>

View File

@@ -1,4 +1,3 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -48,8 +47,23 @@ export default function TeamDetails() {
state: "",
zip_code: "",
manager_name: "",
manager_email: ""
manager_email: "",
departments: []
});
const [showAddDepartmentDialog, setShowAddDepartmentDialog] = useState(false);
const [selectedHub, setSelectedHub] = useState(null);
const [newDepartment, setNewDepartment] = useState({
department_name: "",
cost_center: "",
manager_name: ""
});
const [favoriteSearch, setFavoriteSearch] = useState("");
const [blockedSearch, setBlockedSearch] = useState("");
const [showAddFavoriteDialog, setShowAddFavoriteDialog] = useState(false);
const [showAddBlockedDialog, setShowAddBlockedDialog] = useState(false);
const [blockReason, setBlockReason] = useState("");
const { data: user } = useQuery({
queryKey: ['current-user-team-details'],
@@ -84,6 +98,13 @@ export default function TeamDetails() {
enabled: !!teamId,
initialData: [],
});
const { data: allStaff = [] } = useQuery({
queryKey: ['staff-for-favorites'],
queryFn: () => base44.entities.Staff.list(),
enabled: !!teamId,
initialData: [],
});
const updateMemberMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.TeamMember.update(id, data),
@@ -200,7 +221,8 @@ export default function TeamDetails() {
state: "",
zip_code: "",
manager_name: "",
manager_email: ""
manager_email: "",
departments: []
});
toast({
title: "Hub Created",
@@ -208,6 +230,78 @@ export default function TeamDetails() {
});
},
});
const updateTeamMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Team.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team', teamId] });
toast({
title: "Updated",
description: "Team updated successfully",
});
},
});
const addToFavorites = (staff) => {
const favoriteStaff = team.favorite_staff || [];
const newFavorite = {
staff_id: staff.id,
staff_name: staff.employee_name,
position: staff.position,
added_date: new Date().toISOString()
};
updateTeamMutation.mutate({
id: teamId,
data: {
favorite_staff: [...favoriteStaff, newFavorite],
favorite_staff_count: favoriteStaff.length + 1
}
});
setShowAddFavoriteDialog(false);
};
const removeFromFavorites = (staffId) => {
const favoriteStaff = (team.favorite_staff || []).filter(f => f.staff_id !== staffId);
updateTeamMutation.mutate({
id: teamId,
data: {
favorite_staff: favoriteStaff,
favorite_staff_count: favoriteStaff.length
}
});
};
const addToBlocked = (staff) => {
const blockedStaff = team.blocked_staff || [];
const newBlocked = {
staff_id: staff.id,
staff_name: staff.employee_name,
reason: blockReason,
blocked_date: new Date().toISOString()
};
updateTeamMutation.mutate({
id: teamId,
data: {
blocked_staff: [...blockedStaff, newBlocked],
blocked_staff_count: blockedStaff.length + 1
}
});
setShowAddBlockedDialog(false);
setBlockReason("");
};
const removeFromBlocked = (staffId) => {
const blockedStaff = (team.blocked_staff || []).filter(b => b.staff_id !== staffId);
updateTeamMutation.mutate({
id: teamId,
data: {
blocked_staff: blockedStaff,
blocked_staff_count: blockedStaff.length
}
});
};
const handleEditMember = (member) => {
setEditingMember(member);
@@ -559,7 +653,7 @@ export default function TeamDetails() {
)}
</div>
</div>
<div className="space-y-2 text-sm">
<div className="space-y-2 text-sm mb-4">
{hub.address && <p className="text-slate-600">{hub.address}</p>}
{hub.city && (
<p className="text-slate-600">
@@ -570,6 +664,38 @@ export default function TeamDetails() {
<p className="text-slate-600">{hub.manager_email}</p>
)}
</div>
{hub.departments && hub.departments.length > 0 && (
<div className="border-t pt-4 mt-4">
<p className="text-xs font-semibold text-slate-600 mb-2">DEPARTMENTS</p>
<div className="space-y-2">
{hub.departments.map((dept, idx) => (
<div key={idx} className="bg-slate-50 p-2 rounded text-xs">
<p className="font-semibold text-slate-900">{dept.department_name}</p>
{dept.cost_center && (
<p className="text-slate-600">Cost Center: {dept.cost_center}</p>
)}
{dept.manager_name && (
<p className="text-slate-600">Manager: {dept.manager_name}</p>
)}
</div>
))}
</div>
</div>
)}
<Button
variant="outline"
size="sm"
className="w-full mt-4"
onClick={() => {
setSelectedHub(hub);
setShowAddDepartmentDialog(true);
}}
>
<Plus className="w-3 h-3 mr-2" />
Add Department
</Button>
</CardContent>
</Card>
))
@@ -592,10 +718,69 @@ export default function TeamDetails() {
{/* Favorite Staff Tab */}
<TabsContent value="favorite" className="mt-6">
<Card className="border-slate-200">
<CardContent className="p-12 text-center">
<Star className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Favorite Staff</h3>
<p className="text-slate-500">Mark staff as favorites to see them here</p>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search favorite staff..."
value={favoriteSearch}
onChange={(e) => setFavoriteSearch(e.target.value)}
className="pl-10"
/>
</div>
<Button onClick={() => setShowAddFavoriteDialog(true)} className="bg-[#0A39DF]">
<Star className="w-4 h-4 mr-2" />
Add Favorite
</Button>
</div>
{team.favorite_staff && team.favorite_staff.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{team.favorite_staff.filter(f =>
!favoriteSearch ||
f.staff_name?.toLowerCase().includes(favoriteSearch.toLowerCase()) ||
f.position?.toLowerCase().includes(favoriteSearch.toLowerCase())
).map((fav) => (
<Card key={fav.staff_id} className="border-amber-200 bg-amber-50">
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<Avatar className="w-12 h-12 border-2 border-amber-300">
<AvatarFallback className="bg-amber-200 text-amber-700 font-bold">
{fav.staff_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-bold text-slate-900">{fav.staff_name}</p>
<p className="text-xs text-slate-600">{fav.position}</p>
</div>
</div>
<Star className="w-5 h-5 text-amber-500 fill-amber-500" />
</div>
<Button
variant="outline"
size="sm"
onClick={() => removeFromFavorites(fav.staff_id)}
className="w-full border-amber-300 hover:bg-amber-100 text-xs"
>
Remove
</Button>
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-center py-12">
<Star className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Favorite Staff</h3>
<p className="text-slate-500 mb-4">Mark staff as favorites to see them here</p>
<Button onClick={() => setShowAddFavoriteDialog(true)} className="bg-[#0A39DF]">
<Star className="w-4 h-4 mr-2" />
Add Your First Favorite
</Button>
</div>
)}
</CardContent>
</Card>
</TabsContent>
@@ -603,10 +788,64 @@ export default function TeamDetails() {
{/* Blocked Staff Tab */}
<TabsContent value="blocked" className="mt-6">
<Card className="border-slate-200">
<CardContent className="p-12 text-center">
<UserX className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Blocked Staff</h3>
<p className="text-slate-500">Blocked staff will appear here</p>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search blocked staff..."
value={blockedSearch}
onChange={(e) => setBlockedSearch(e.target.value)}
className="pl-10"
/>
</div>
<Button onClick={() => setShowAddBlockedDialog(true)} variant="outline" className="border-red-300 text-red-600 hover:bg-red-50">
<UserX className="w-4 h-4 mr-2" />
Block Staff
</Button>
</div>
{team.blocked_staff && team.blocked_staff.length > 0 ? (
<div className="space-y-3">
{team.blocked_staff.filter(b =>
!blockedSearch ||
b.staff_name?.toLowerCase().includes(blockedSearch.toLowerCase())
).map((blocked) => (
<Card key={blocked.staff_id} className="border-red-200 bg-red-50">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar className="w-12 h-12 border-2 border-red-300">
<AvatarFallback className="bg-red-200 text-red-700 font-bold">
{blocked.staff_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className="font-bold text-slate-900">{blocked.staff_name}</p>
<p className="text-xs text-slate-600 mt-1"><strong>Reason:</strong> {blocked.reason || 'No reason provided'}</p>
<p className="text-[10px] text-slate-500 mt-1">Blocked {new Date(blocked.blocked_date).toLocaleDateString()}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => removeFromBlocked(blocked.staff_id)}
className="border-red-300 hover:bg-red-100 text-red-600 text-xs"
>
Unblock
</Button>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-center py-12">
<UserX className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Blocked Staff</h3>
<p className="text-slate-500">Blocked staff will appear here</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
@@ -806,7 +1045,7 @@ export default function TeamDetails() {
<Input
value={newHub.hub_name}
onChange={(e) => setNewHub({ ...newHub, hub_name: e.target.value })}
placeholder="Downtown Office"
placeholder="BVG 300"
/>
</div>
<div className="col-span-2">
@@ -814,7 +1053,7 @@ export default function TeamDetails() {
<Input
value={newHub.address}
onChange={(e) => setNewHub({ ...newHub, address: e.target.value })}
placeholder="123 Main Street"
placeholder="300 Bayview Dr, Mountain View, CA 94043"
/>
</div>
<div>
@@ -822,7 +1061,7 @@ export default function TeamDetails() {
<Input
value={newHub.city}
onChange={(e) => setNewHub({ ...newHub, city: e.target.value })}
placeholder="San Francisco"
placeholder="Mountain View"
/>
</div>
<div>
@@ -838,7 +1077,7 @@ export default function TeamDetails() {
<Input
value={newHub.zip_code}
onChange={(e) => setNewHub({ ...newHub, zip_code: e.target.value })}
placeholder="94102"
placeholder="94043"
/>
</div>
<div>
@@ -867,7 +1106,139 @@ export default function TeamDetails() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Department Dialog */}
<Dialog open={showAddDepartmentDialog} onOpenChange={setShowAddDepartmentDialog}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Add Department to {selectedHub?.hub_name}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Department Name *</Label>
<Input
value={newDepartment.department_name}
onChange={(e) => setNewDepartment({ ...newDepartment, department_name: e.target.value })}
placeholder="Catering FOH or Catering BOH"
/>
</div>
<div>
<Label>Cost Center</Label>
<Input
value={newDepartment.cost_center}
onChange={(e) => setNewDepartment({ ...newDepartment, cost_center: e.target.value })}
placeholder="CC-12345"
/>
</div>
<div>
<Label>Department Manager</Label>
<Input
value={newDepartment.manager_name}
onChange={(e) => setNewDepartment({ ...newDepartment, manager_name: e.target.value })}
placeholder="Manager name"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => {
setShowAddDepartmentDialog(false);
setNewDepartment({ department_name: "", cost_center: "", manager_name: "" });
}}>Cancel</Button>
<Button
onClick={async () => {
const updatedDepartments = [...(selectedHub.departments || []), newDepartment];
await base44.entities.TeamHub.update(selectedHub.id, {
departments: updatedDepartments
});
queryClient.invalidateQueries({ queryKey: ['team-hubs', teamId] });
setShowAddDepartmentDialog(false);
setNewDepartment({ department_name: "", cost_center: "", manager_name: "" });
toast({ title: "Department Added", description: "Department created successfully" });
}}
className="bg-[#0A39DF]"
disabled={!newDepartment.department_name}
>
Add Department
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Favorite Staff Dialog */}
<Dialog open={showAddFavoriteDialog} onOpenChange={setShowAddFavoriteDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Add Favorite Staff</DialogTitle>
</DialogHeader>
<div className="max-h-96 overflow-y-auto space-y-2">
{allStaff.filter(s => !(team.favorite_staff || []).some(f => f.staff_id === s.id)).map((staff) => (
<Card key={staff.id} className="cursor-pointer hover:bg-blue-50 transition-colors" onClick={() => addToFavorites(staff)}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Avatar className="w-10 h-10">
<AvatarFallback className="bg-[#0A39DF] text-white">
{staff.employee_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">{staff.employee_name}</p>
<p className="text-xs text-slate-500">{staff.position}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddFavoriteDialog(false)}>Cancel</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Blocked Staff Dialog */}
<Dialog open={showAddBlockedDialog} onOpenChange={setShowAddBlockedDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Block Staff Member</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Reason for blocking *</Label>
<Input
value={blockReason}
onChange={(e) => setBlockReason(e.target.value)}
placeholder="Performance issues, policy violation, etc."
/>
</div>
<div className="max-h-64 overflow-y-auto space-y-2">
{allStaff.filter(s => !(team.blocked_staff || []).some(b => b.staff_id === s.id)).map((staff) => (
<Card key={staff.id} className="cursor-pointer hover:bg-red-50 transition-colors" onClick={() => addToBlocked(staff)}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Avatar className="w-10 h-10">
<AvatarFallback className="bg-slate-200 text-slate-700">
{staff.employee_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">{staff.employee_name}</p>
<p className="text-xs text-slate-500">{staff.position}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => {
setShowAddBlockedDialog(false);
setBlockReason("");
}}>Cancel</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@ import {
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 } from "lucide-react";
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() {
@@ -287,29 +287,25 @@ export default function VendorMarketplace() {
<div className="max-w-[1600px] mx-auto space-y-6">
{/* Hero Header */}
<div className="relative overflow-hidden bg-gradient-to-r from-[#0A39DF] to-[#1C323E] rounded-xl p-8 shadow-lg">
<div className="absolute inset-0 opacity-5" style={{
backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)',
backgroundSize: '30px 30px'
}} />
<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/10 backdrop-blur-sm rounded-xl flex items-center justify-center">
<Building2 className="w-7 h-7 text-white" />
<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-white">Vendor Marketplace</h1>
<p className="text-blue-100 text-sm mt-1">Find the perfect vendor partner for your staffing needs</p>
<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/10 backdrop-blur-sm rounded-lg">
<Users className="w-4 h-4 text-white" />
<span className="text-white font-semibold">{filteredVendors.length} Active Vendors</span>
<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/10 backdrop-blur-sm rounded-lg">
<Star className="w-4 h-4 text-amber-300 fill-amber-300" />
<span className="text-white font-semibold">Verified & Approved</span>
<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>
@@ -322,11 +318,11 @@ export default function VendorMarketplace() {
<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-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center shadow-md">
<Crown className="w-8 h-8 text-amber-400" />
<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-amber-500 rounded-full border-2 border-white flex items-center justify-center shadow-md">
<Star className="w-3 h-3 text-white fill-white" />
<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>
@@ -354,32 +350,32 @@ export default function VendorMarketplace() {
<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 rounded-lg border border-slate-200">
<Users className="w-5 h-5 mx-auto mb-2 text-[#0A39DF]" />
<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-amber-50 rounded-lg border border-amber-200">
<Star className="w-5 h-5 mx-auto mb-2 text-amber-600 fill-amber-600" />
<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-emerald-50 rounded-lg border border-emerald-200">
<Target className="w-5 h-5 mx-auto mb-2 text-emerald-600" />
<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-purple-50 rounded-lg border border-purple-200">
<Clock className="w-5 h-5 mx-auto mb-2 text-purple-600" />
<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-indigo-50 rounded-lg border border-indigo-200">
<DollarSign className="w-5 h-5 mx-auto mb-2 text-indigo-600" />
<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>
@@ -394,32 +390,32 @@ export default function VendorMarketplace() {
{/* 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-green-500 rounded-lg flex items-center justify-center flex-shrink-0">
<Zap className="w-4 h-4 text-white" />
<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-green-900 text-sm">Priority Support</p>
<p className="text-xs text-green-700">Faster responses</p>
<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-[#0A39DF] rounded-lg flex items-center justify-center flex-shrink-0">
<Shield className="w-4 h-4 text-white" />
<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-[#1C323E] text-sm">Dedicated Manager</p>
<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-indigo-50 border border-indigo-200 rounded-lg p-3 flex items-center gap-3">
<div className="w-9 h-9 bg-indigo-600 rounded-lg flex items-center justify-center flex-shrink-0">
<TrendingUp className="w-4 h-4 text-white" />
<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-indigo-900 text-sm">Better Rates</p>
<p className="text-xs text-indigo-700">Volume pricing</p>
<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>
@@ -457,7 +453,7 @@ export default function VendorMarketplace() {
{/* Stats Cards */}
<div className="grid grid-cols-4 gap-4">
<Card className="border border-slate-200 bg-white hover:border-[#0A39DF] hover:shadow-md transition-all">
<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>
@@ -465,46 +461,14 @@ export default function VendorMarketplace() {
<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-blue-50 rounded-xl flex items-center justify-center">
<Building2 className="w-6 h-6 text-[#0A39DF]" />
<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-slate-200 bg-white hover:border-emerald-500 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">Staff</p>
<p className="text-3xl font-bold text-slate-900 mb-0.5">{staff.length}</p>
<p className="text-slate-500 text-xs">Available</p>
</div>
<div className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center">
<Users className="w-6 h-6 text-emerald-600" />
</div>
</div>
</CardContent>
</Card>
<Card className="border border-slate-200 bg-white hover:border-indigo-500 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">Avg Rate</p>
<p className="text-3xl font-bold text-slate-900 mb-0.5">
${Math.round(vendorsWithMetrics.reduce((sum, v) => sum + v.avgRate, 0) / vendorsWithMetrics.length || 0)}
</p>
<p className="text-slate-500 text-xs">Per hour</p>
</div>
<div className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center">
<DollarSign className="w-6 h-6 text-indigo-600" />
</div>
</div>
</CardContent>
</Card>
<Card className="border border-slate-200 bg-white hover:border-amber-500 hover:shadow-md transition-all">
<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>
@@ -515,12 +479,42 @@ export default function VendorMarketplace() {
</div>
<p className="text-slate-500 text-xs">Average</p>
</div>
<div className="w-12 h-12 bg-amber-50 rounded-xl flex items-center justify-center">
<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 */}
@@ -635,13 +629,16 @@ export default function VendorMarketplace() {
const isExpanded = expandedVendors[vendor.id];
return (
<Card key={vendor.id} className="bg-white border border-slate-200 hover:border-[#0A39DF] hover:shadow-lg transition-all group">
<CardHeader className="bg-slate-50 border-b border-slate-200 pb-4">
<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-gradient-to-br from-[#0A39DF] to-indigo-600 shadow-lg ring-2 ring-blue-200">
<AvatarFallback className="text-white text-xl font-bold">
<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>
@@ -652,12 +649,12 @@ export default function VendorMarketplace() {
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<CardTitle className="text-xl font-bold text-[#1C323E] group-hover:text-[#0A39DF] transition-colors">
<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-amber-50 px-3 py-1.5 rounded-full border border-amber-200">
<Star className="w-4 h-4 text-amber-600 fill-amber-600" />
<span className="text-sm font-bold text-amber-700">{vendor.rating.toFixed(1)}</span>
<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>
@@ -667,20 +664,20 @@ export default function VendorMarketplace() {
<div className="flex items-center gap-4 flex-wrap">
{vendor.service_specialty && (
<Badge className="bg-blue-100 text-blue-700">
<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-[#0A39DF]" />
<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-[#0A39DF]" />
<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-emerald-600" />
<Clock className="w-4 h-4 text-teal-600" />
{vendor.responseTime}
</span>
</div>
@@ -688,19 +685,19 @@ export default function VendorMarketplace() {
</div>
<div className="flex flex-col items-end gap-3">
<div className="p-4 bg-gradient-to-br from-[#0A39DF] to-indigo-600 rounded-xl shadow-lg text-center min-w-[140px]">
<p className="text-blue-100 text-[10px] mb-1 font-semibold uppercase tracking-wide">Starting from</p>
<p className="text-3xl font-bold text-white mb-1">${vendor.minRate}</p>
<p className="text-blue-200 text-xs">per hour</p>
<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-gradient-to-br from-purple-50 to-pink-50 border-2 border-purple-300 rounded-xl px-4 py-3 shadow-md min-w-[140px]">
<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-purple-700" />
<span className="text-2xl font-bold text-purple-700">{vendor.clientsInSector}</span>
<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-purple-600 font-bold text-center uppercase tracking-wide">
<p className="text-[10px] text-slate-600 font-bold text-center uppercase tracking-wide">
in your area
</p>
</div>
@@ -711,7 +708,7 @@ export default function VendorMarketplace() {
<CheckCircle className="w-3 h-3 mr-1" />
{vendor.completedJobs} jobs
</Badge>
<Badge variant="outline" className="border-slate-300 px-3 py-1.5 text-xs font-semibold">
<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>
@@ -719,17 +716,17 @@ export default function VendorMarketplace() {
</div>
</CardHeader>
<div className="px-5 py-4 bg-white border-b border-slate-100">
<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-blue-100 rounded-lg flex items-center justify-center">
<TrendingUp className="w-4 h-4 text-[#0A39DF]" />
<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-[#1C323E] text-base">Compare Rates</span>
<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" />}
@@ -742,22 +739,21 @@ export default function VendorMarketplace() {
<Button
onClick={() => setPreferredMutation.mutate(vendor)}
disabled={setPreferredMutation.isPending}
className="bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 font-bold shadow-md"
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
variant="outline"
onClick={() => handleContactVendor(vendor)}
className="border-2 hover:border-[#0A39DF] hover:bg-blue-50"
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-gradient-to-r from-emerald-600 to-green-600 hover:from-emerald-700 hover:to-green-700 shadow-md"
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
@@ -768,69 +764,39 @@ export default function VendorMarketplace() {
<Collapsible open={isExpanded}>
<CollapsibleContent>
<CardContent className="p-6 bg-gradient-to-br from-slate-50 to-blue-50/20">
<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-[#0A39DF] to-indigo-600 px-5 py-3">
<h4 className="font-bold text-white text-sm flex items-center gap-2">
<Briefcase className="w-4 h-4" />
<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-white/20 text-white border-0 ml-auto">
<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) => {
const baseWage = rate.employee_wage || 0;
const markupAmount = baseWage * ((rate.markup_percentage || 0) / 100);
const feeAmount = (baseWage + markupAmount) * ((rate.vendor_fee_percentage || 0) / 100);
return (
<div key={rate.id} className="p-4 hover:bg-blue-50 transition-all">
<div className="flex items-center justify-between gap-6">
<div className="flex-1">
<div className="flex items-center gap-2 mb-3">
<div className="w-7 h-7 bg-slate-100 rounded-lg flex items-center justify-center font-bold text-slate-600 text-sm">
{idx + 1}
</div>
<h5 className="font-bold text-[#1C323E] text-base">{rate.role_name}</h5>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs">
<span className="w-24 text-slate-600 font-medium">Base Wage:</span>
<div className="flex-1 h-7 bg-gradient-to-r from-emerald-500 to-emerald-600 rounded-lg flex items-center justify-between px-3 text-white font-semibold">
<span>${baseWage.toFixed(2)}/hr</span>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="w-24 text-slate-600 font-medium">+ Markup:</span>
<div className="flex-1 h-7 bg-gradient-to-r from-blue-500 to-blue-600 rounded-lg flex items-center justify-between px-3 text-white font-semibold">
<span>{rate.markup_percentage}% (+${markupAmount.toFixed(2)})</span>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="w-24 text-slate-600 font-medium">+ Admin Fee:</span>
<div className="flex-1 h-7 bg-gradient-to-r from-purple-500 to-purple-600 rounded-lg flex items-center justify-between px-3 text-white font-semibold">
<span>{rate.vendor_fee_percentage}% (+${feeAmount.toFixed(2)})</span>
</div>
</div>
</div>
</div>
<div className="flex flex-col items-center">
<p className="text-[10px] text-slate-500 mb-2 font-bold uppercase">You Pay</p>
<div className="bg-gradient-to-br from-[#0A39DF] to-indigo-600 rounded-xl px-6 py-4 shadow-lg">
<p className="text-3xl font-bold text-white">${rate.client_rate?.toFixed(0)}</p>
<p className="text-blue-200 text-xs text-center mt-1">per hour</p>
</div>
{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>
))}
@@ -860,16 +826,19 @@ export default function VendorMarketplace() {
</thead>
<tbody>
{otherVendors.map((vendor) => (
<tr key={vendor.id} className="border-b border-slate-100 hover:bg-blue-50/50 transition-all">
<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-gradient-to-br from-[#0A39DF] to-indigo-600 shadow-md">
<AvatarFallback className="text-white font-bold">
<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-[#1C323E]">{vendor.legal_name}</p>
<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>
@@ -877,19 +846,19 @@ export default function VendorMarketplace() {
<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-[#0A39DF]" />
<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-amber-50 px-3 py-1.5 rounded-full">
<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-amber-700">{vendor.rating.toFixed(1)}</span>
<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-purple-100 text-purple-700">
<Badge className="bg-blue-100 text-blue-700 border border-blue-200">
<UserCheck className="w-3 h-3 mr-1" />
{vendor.clientsInSector}
</Badge>
@@ -898,12 +867,12 @@ export default function VendorMarketplace() {
)}
</td>
<td className="py-5 px-5 text-center">
<Badge variant="outline" className="font-bold">{vendor.staffCount}</Badge>
<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 px-4 py-2 rounded-xl">
<span className="font-bold text-xl text-[#0A39DF]">${vendor.minRate}</span>
<span className="text-xs text-slate-500">/hour</span>
<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">
@@ -912,15 +881,15 @@ export default function VendorMarketplace() {
size="sm"
onClick={() => setPreferredMutation.mutate(vendor)}
disabled={setPreferredMutation.isPending}
className="bg-blue-600 hover:bg-blue-700"
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"
variant="outline"
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
@@ -968,8 +937,11 @@ export default function VendorMarketplace() {
<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-gradient-to-br from-[#0A39DF] to-indigo-600 ring-2 ring-white shadow-md">
<AvatarFallback className="text-white text-xl font-bold">
<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>

View File

@@ -130,6 +130,10 @@ import NotificationSettings from "./NotificationSettings";
import TaskBoard from "./TaskBoard";
import InvoiceDetail from "./InvoiceDetail";
import InvoiceEditor from "./InvoiceEditor";
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
const PAGES = {
@@ -264,6 +268,10 @@ const PAGES = {
TaskBoard: TaskBoard,
InvoiceDetail: InvoiceDetail,
InvoiceEditor: InvoiceEditor,
}
function _getCurrentPage(url) {
@@ -421,6 +429,10 @@ function PagesContent() {
<Route path="/TaskBoard" element={<TaskBoard />} />
<Route path="/InvoiceDetail" element={<InvoiceDetail />} />
<Route path="/InvoiceEditor" element={<InvoiceEditor />} />
</Routes>
</Layout>
);