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