fix: Verify functionality of Add Client and Client List

This commit is contained in:
dhinesh-m24
2026-02-05 12:18:55 +05:30
parent 6b537af46e
commit 248afbb0ae
9 changed files with 1147 additions and 4 deletions

View File

@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@firebase/analytics": "^0.10.19", "@firebase/analytics": "^0.10.19",
"@firebase/data-connect": "^0.3.12", "@firebase/data-connect": "^0.3.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",

View File

@@ -17,6 +17,9 @@ importers:
'@firebase/data-connect': '@firebase/data-connect':
specifier: ^0.3.12 specifier: ^0.3.12
version: 0.3.12(@firebase/app@0.14.7) version: 0.3.12(@firebase/app@0.14.7)
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-label': '@radix-ui/react-label':
specifier: ^2.1.7 specifier: ^2.1.7
version: 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)

View File

@@ -0,0 +1,119 @@
import React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,25 @@
// Simplified use-toast hook
import { useState } from "react";
type ToastProps = {
title?: string;
description?: string;
variant?: "default" | "destructive";
};
export const useToast = () => {
const [toasts, setToasts] = useState<ToastProps[]>([]);
const toast = ({ title, description, variant = "default" }: ToastProps) => {
const newToast = { title, description, variant };
setToasts((prev) => [...prev, newToast]);
console.log("Toast:", title, description);
// Auto dismiss after 3 seconds
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t !== newToast));
}, 3000);
};
return { toast, toasts };
};

View File

@@ -18,13 +18,14 @@ import { useSelector } from "react-redux";
import type { RootState } from "@/store/store"; import type { RootState } from "@/store/store";
import { import {
useCreateBusiness, useCreateBusiness,
useCreateTeamHub useCreateTeamHub,
useCreateTeam
} from "@/dataconnect-generated/react"; } from "@/dataconnect-generated/react";
import { import {
BusinessArea, BusinessArea,
BusinessSector, BusinessSector,
BusinessStatus, BusinessStatus,
BusinessRateGroup BusinessRateGroup,
} from "@/dataconnect-generated"; } from "@/dataconnect-generated";
import { dataConnect } from "@/features/auth/firebase"; import { dataConnect } from "@/features/auth/firebase";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
@@ -55,6 +56,7 @@ export default function AddClient() {
const { mutateAsync: createBusiness, isPending: isCreatingBusiness } = useCreateBusiness(dataConnect); const { mutateAsync: createBusiness, isPending: isCreatingBusiness } = useCreateBusiness(dataConnect);
const { mutateAsync: createHub, isPending: isCreatingHub } = useCreateTeamHub(dataConnect); const { mutateAsync: createHub, isPending: isCreatingHub } = useCreateTeamHub(dataConnect);
const { mutateAsync: createTeam, isPending: isCreatingTeam } = useCreateTeam(dataConnect);
const handleChange = (field: string, value: any) => { const handleChange = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData(prev => ({ ...prev, [field]: value }));
@@ -82,12 +84,35 @@ export default function AddClient() {
status: formData.status, status: formData.status,
notes: formData.notes notes: formData.notes
}); });
console.log("Business created:", businessResult);
const businessId = businessResult.business_insert.id; const businessId = businessResult.business_insert.id;
if (!businessId) {
throw new Error("Business creation failed — no ID returned.");
}
// Create the team for this business
const teamResult = await createTeam({
teamName: `${formData.businessName} Team`,
ownerId: businessId,
ownerName: formData.contactName,
ownerRole: "ADMIN",
email: formData.email,
companyLogo: formData.companyLogoUrl || null,
totalMembers: 0,
activeMembers: 0,
totalHubs: 0
});
const teamId = teamResult.team_insert.id;
if (!teamId) {
throw new Error("Team creation failed — no ID returned.");
}
// 2. Automatically create the client's first "hub" or location // 2. Automatically create the client's first "hub" or location
await createHub({ await createHub({
teamId: businessId, teamId: teamId,
hubName: `${formData.businessName} - Main Hub`, hubName: `${formData.businessName} - Main Hub`,
address: formData.address || "Main Office", address: formData.address || "Main Office",
city: formData.city, city: formData.city,

View File

@@ -172,7 +172,7 @@ export default function ClientList() {
<SelectItem value="all">All Statuses</SelectItem> <SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem> <SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="PENDING">Pending</SelectItem> <SelectItem value="PENDING">Pending</SelectItem>
<SelectItem value="INACTIVE">Inactive</SelectItem> <SelectItem value="SUSPENDED">Suspended</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={industryFilter} onValueChange={setIndustryFilter}> <Select value={industryFilter} onValueChange={setIndustryFilter}>

View File

@@ -0,0 +1,823 @@
import { Badge } from "@/common/components/ui/badge";
import { Button } from "@/common/components/ui/button";
import { Card, CardContent } from "@/common/components/ui/card";
import { Input } from "@/common/components/ui/input";
import { useToast } from "@/common/components/ui/use-toast";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import { useQueryClient } from "@tanstack/react-query";
import {
BarChart3,
Briefcase,
DollarSign,
Download,
FileText,
Filter,
MapPin,
Pencil,
Plus,
Search,
Shield,
Sparkles,
Trash2
} from "lucide-react";
import { useMemo, useState } from "react";
import { useSelector } from "react-redux";
import {
useListVendorRates,
useListCustomRateCards,
useCreateCustomRateCard,
useUpdateCustomRateCard,
useDeleteCustomRateCard,
useCreateVendorRate,
useUpdateVendorRate,
useDeleteVendorRate,
useGetVendorByUserId
} from "@/dataconnect-generated/react";
import RateCardModal from "./components/RateCardModal";
// --- Constants & Helper Functions ---
function fmtCurrency(v: number | undefined | null) {
if (typeof v !== "number" || Number.isNaN(v)) return "—";
return v.toLocaleString(undefined, { style: "currency", currency: "USD" });
}
function downloadCSV(rows: any[], regionName: string, vendorName: string) {
const headers = [
"Role",
"Category",
"Employee Wage",
"Markup %",
"Vendor Fee %",
"Client Rate",
];
const lines = [headers.join(",")];
for (const r of rows) {
const cells = [
r.role_name,
r.category,
r.employee_wage,
r.markup_percentage,
r.vendor_fee_percentage,
r.client_rate,
];
lines.push(cells.join(","));
}
const blob = new Blob([lines.join("\n")], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${vendorName}_${regionName}_Rates_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
const parseRoleName = (roleName: string) => {
if (!roleName) return { position: "", region: "" };
if (roleName.includes(" - ")) {
const parts = roleName.split(" - ");
return {
position: parts[0].trim(),
region: parts[1].trim(),
};
}
return {
position: roleName,
region: "",
};
};
// --- Sub-Components ---
function VendorCompanyPricebookView({
vendorName,
}: {
vendorName: string;
}) {
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: vendorRatesData } = useListVendorRates();
const vendorRates = vendorRatesData?.vendorRates || [];
const { data: customRateCardsData } = useListCustomRateCards();
const customRateCards = customRateCardsData?.customRateCards || [];
const { mutate: createCustomRateCard } = useCreateCustomRateCard();
const { mutate: updateCustomRateCard } = useUpdateCustomRateCard();
const { mutate: deleteCustomRateCard } = useDeleteCustomRateCard();
const { mutate: createVendorRate } = useCreateVendorRate();
const { mutate: updateVendorRate } = useUpdateVendorRate();
const { mutate: deleteVendorRate } = useDeleteVendorRate();
const handleUpdateVendorRate = (vars: any) => {
updateVendorRate(vars, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listVendorRates"] });
}
});
};
const [pricebook, setPricebook] = useState("Standard");
const [search, setSearch] = useState("");
const [activeRegion, setActiveRegion] = useState("All");
const [activeCategory, setActiveCategory] = useState("All");
const [editing, setEditing] = useState<string | null>(null);
const [analyzingCompetitiveness, setAnalyzingCompetitiveness] =
useState(false);
const [competitivenessData, setCompetitivenessData] = useState<any[] | null>(
null,
);
const [showRateCardModal, setShowRateCardModal] = useState(false);
const [editingRateCard, setEditingRateCard] = useState<any | null>(null);
const [renamingCard, setRenamingCard] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const RATE_CARDS = customRateCards.map((c: any) => c.name);
const rates = useMemo(() => {
return vendorRates.filter((r) => r.vendor?.companyName === vendorName && r.isActive);
}, [vendorRates, vendorName]);
const CATEGORIES = useMemo(() => {
const cats = new Set(vendorRates.map(r => r.category).filter(Boolean));
return Array.from(cats);
}, [vendorRates]);
const handleSaveRateCard = (cardData: any) => {
if (editingRateCard) {
updateCustomRateCard({
id: editingRateCard.id,
name: cardData.name,
baseBook: cardData.baseBook,
discount: cardData.discount,
isDefault: editingRateCard.isDefault
}, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listCustomRateCards"] });
toast({ title: "Rate card updated successfully" });
}
});
} else {
createCustomRateCard({
name: cardData.name,
baseBook: cardData.baseBook,
discount: cardData.discount,
isDefault: false
}, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listCustomRateCards"] });
toast({ title: "Rate card saved successfully" });
}
});
}
setPricebook(cardData.name);
setEditingRateCard(null);
};
const handleDeleteRateCard = (cardId: string, cardName: string) => {
if (customRateCards.length <= 1) {
toast({
title: "Cannot delete the last rate card",
variant: "destructive",
});
return;
}
deleteCustomRateCard({ id: cardId }, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listCustomRateCards"] });
toast({ title: "Rate card deleted" });
if (pricebook === cardName) {
const nextCard = customRateCards.find((c: any) => c.id !== cardId);
setPricebook(nextCard ? nextCard.name : "Standard");
}
}
});
};
const handleRenameCard = (cardId: string, oldName: string) => {
if (!renameValue.trim() || renameValue === oldName) {
setRenamingCard(null);
return;
}
const currentCard = customRateCards.find((c: any) => c.id === cardId);
if (currentCard) {
updateCustomRateCard({
id: cardId,
name: renameValue.trim(),
baseBook: currentCard.baseBook,
discount: currentCard.discount,
isDefault: currentCard.isDefault
}, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listCustomRateCards"] });
if (pricebook === oldName) {
setPricebook(renameValue.trim());
}
}
});
}
setRenamingCard(null);
};
const scopedByBook = useMemo(() => {
const isCustomRateCard = customRateCards.find(c => c.name === pricebook);
const discount = isCustomRateCard?.discount || 0;
return rates.map((r: any) => {
const parsed = parseRoleName(r.roleName || "");
const position = parsed.position;
// Apply discount if it's a custom rate card
const proposedRate = r.clientRate * (1 - discount / 100);
const assignedRegion =
r.vendor?.region ||
parsed.region ||
(r.notes?.includes("Bay Area") ? "Bay Area" : "LA");
return {
...r,
client: pricebook,
region: assignedRegion,
approvedCap: r.clientRate,
proposedRate: proposedRate,
position: r.roleName,
markupPct: r.markupPercentage,
volDiscountPct: r.vendorFeePercentage,
overtime8Multiplier: r.overtime8Multiplier || 1.5,
overtime12Multiplier: r.overtime12Multiplier || 2.0,
holidayRate: r.holidayRate || proposedRate * 1.5
};
});
}, [rates, pricebook, customRateCards]);
const APPROVED_RATES = useMemo(() => {
return customRateCards.filter(c => c.isDefault).map(c => c.name);
}, [customRateCards]);
const isApprovedRate = APPROVED_RATES.includes(pricebook) || pricebook === "Standard";
const filtered = useMemo(() => {
return (scopedByBook as any[]).filter((r) => {
const regionMatch =
!isApprovedRate ||
activeRegion === "All" ||
r.region === activeRegion ||
parseRoleName(r.position).region === activeRegion;
const categoryMatch =
activeCategory === "All" || r.category === activeCategory;
const searchMatch =
search.trim() === "" ||
parseRoleName(r.position)
.position.toLowerCase()
.includes(search.toLowerCase());
return regionMatch && categoryMatch && searchMatch;
});
}, [scopedByBook, activeRegion, activeCategory, search, isApprovedRate]);
const kpis = useMemo(() => {
const rateValues = filtered.map((r) => r.proposedRate);
const avg = rateValues.length
? rateValues.reduce((a, b) => a + b, 0) / rateValues.length
: 0;
const min = rateValues.length ? Math.min(...rateValues) : 0;
const max = rateValues.length ? Math.max(...rateValues) : 0;
const total = filtered.length;
return { avg, min, max, total };
}, [filtered]);
async function analyzeCompetitiveness() {
setAnalyzingCompetitiveness(true);
try {
await new Promise((resolve) => setTimeout(resolve, 1500));
const mockAnalysis = filtered.slice(0, 20).map((r) => ({
position: parseRoleName(r.position).position,
marketRate: r.proposedRate * (0.9 + Math.random() * 0.2),
score: Math.floor(60 + Math.random() * 40),
status: ["Highly Competitive", "Competitive", "Average", "Above Market"][
Math.floor(Math.random() * 4)
],
recommendation: "Consider slight adjustment.",
}));
setCompetitivenessData(mockAnalysis);
toast({ title: "Competitive analysis complete" });
} catch (error) {
console.error("Analysis error:", error);
toast({ title: "Analysis failed. Try again.", variant: "destructive" });
} finally {
setAnalyzingCompetitiveness(false);
}
}
return (
<DashboardLayout
title="Service Rate Management"
subtitle={`20252028 Pricing Structure for ${vendorName}`}
actions={
<>
<Button
onClick={analyzeCompetitiveness}
disabled={analyzingCompetitiveness}
className="bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm"
>
{analyzingCompetitiveness ? (
<>
<div className="w-4 h-4 mr-2 border-2 border-white border-t-transparent rounded-full animate-spin" />
Analyzing...
</>
) : (
<>
<Shield className="w-4 h-4 mr-2" />
AI Price Check
</>
)}
</Button>
<Button
onClick={() => downloadCSV(filtered, activeRegion, vendorName)}
variant="outline"
className="border-dashed border-border"
>
<Download className="w-4 h-4 mr-2" />
Export CSV
</Button>
</>
}
>
<div className="space-y-6">
{/* AI Analysis Result Block - Styled like Dispute Alert */}
{competitivenessData && competitivenessData.length > 0 && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4 mb-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-emerald-100 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm">
<Sparkles className="w-5 h-5 text-emerald-600" />
</div>
<div className="flex-1">
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-bold text-emerald-900">Market Intelligence Analysis Complete</h3>
<p className="text-sm text-emerald-700 mt-1">
Results based on current market data for {pricebook}.
</p>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => setCompetitivenessData(null)}
className="text-emerald-700 hover:bg-emerald-100 -mt-1 -mr-2"
>
Dismiss
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
{[{ "status": "Highly Competitive", "color": "bg-emerald-500", "textColor": "text-emerald-700" }, { "status": "Competitive", "color": "bg-blue-500", "textColor": "text-blue-700" }, { "status": "Average", "color": "bg-yellow-500", "textColor": "text-yellow-700" }, { "status": "Above Market", "color": "bg-red-500", "textColor": "text-red-700" }].map(({ status, color, textColor }) => {
const count = competitivenessData.filter((d) => d.status === status).length;
return (
<div key={status} className="bg-white/60 rounded-lg p-3 border border-emerald-200/50">
<div className="flex items-center gap-2 mb-1">
<div className={`w-2 h-2 rounded-full ${color}`} />
<span className={`text-xs font-bold uppercase ${textColor}`}>{status}</span>
</div>
<p className="text-2xl font-bold text-emerald-900">{count}</p>
</div>
);
})}
</div>
</div>
</div>
</div>
)}
{/* Rate Books Section Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center">
<FilesIcon className="w-6 h-6 text-primary" />
</div>
<div>
<h2 className="text-xl font-bold text-primary-text">Rate Books</h2>
<p className="text-secondary-text text-sm">Manage standard and custom client pricebooks</p>
</div>
</div>
{/* Book Selector Grid - Using bg-muted/20 like Client Selection */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Approved Enterprise Rates */}
<div className="bg-muted/10 p-4 rounded-xl border border-border/30">
<div className="flex items-center gap-3 mb-4 pb-3 border-b border-border/30">
<div className="w-8 h-8 bg-amber-500/10 rounded-lg flex items-center justify-center">
<Shield className="w-4 h-4 text-amber-600" />
</div>
<div>
<h3 className="text-sm font-bold text-primary-text uppercase tracking-wider">
Enterprise Books
</h3>
</div>
</div>
<div className="flex flex-wrap gap-2">
{APPROVED_RATES.map((tab) => (
<button
key={tab}
onClick={() => {
setPricebook(tab);
setActiveRegion("All");
}}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${pricebook === tab
? "bg-white shadow-sm text-amber-700 border-amber-200 border"
: "bg-muted/50 text-secondary-text hover:bg-muted"
}`}
>
{tab}
</button>
))}
</div>
</div>
{/* Custom Rate Cards */}
<div className="bg-muted/10 p-4 rounded-xl border border-border/30">
<div className="flex items-center justify-between mb-4 pb-3 border-b border-border/30">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-500/10 rounded-lg flex items-center justify-center">
<Briefcase className="w-4 h-4 text-blue-600" />
</div>
<div>
<h3 className="text-sm font-bold text-primary-text uppercase tracking-wider">
Custom Cards
</h3>
</div>
</div>
<Button
onClick={() => {
setEditingRateCard(null);
setShowRateCardModal(true);
}}
size="sm"
variant="ghost"
className="h-7 text-xs hover:bg-primary/5 text-primary"
>
<Plus className="w-3.5 h-3.5 mr-1" /> New
</Button>
</div>
<div className="flex flex-wrap gap-2">
{RATE_CARDS.map((tab) => {
const cardData = customRateCards.find((c) => c.name === tab);
const isRenaming = renamingCard === tab;
if (isRenaming) {
return (
<div key={tab} className="flex items-center gap-1">
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => handleRenameCard(cardData.id, tab)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRenameCard(cardData.id, tab);
if (e.key === "Escape") setRenamingCard(null);
}}
autoFocus
className="px-3 py-2 rounded-lg text-sm font-semibold border border-primary bg-white focus:outline-none w-40"
/>
</div>
);
}
return (
<div key={tab} className="relative group">
<button
onClick={() => setPricebook(tab)}
onDoubleClick={(e) => {
e.stopPropagation();
setRenamingCard(tab);
setRenameValue(tab);
}}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all flex items-center gap-2 ${pricebook === tab
? "bg-white shadow-sm text-blue-700 border-blue-200 border"
: "bg-muted/50 text-secondary-text hover:bg-muted"
}`}
title="Double-click to rename"
>
{tab}
{cardData?.discount && cardData.discount > 0 && (
<span className="text-xs opacity-80 px-1 py-0.5 bg-green-500/20 rounded">
-{cardData.discount}%
</span>
)}
</button>
{/* Hover actions simplified */}
<div className="absolute -top-1 -right-1 hidden group-hover:flex">
<button onClick={(e) => { e.stopPropagation(); handleDeleteRateCard(cardData.id, tab); }} className="p-1 bg-white rounded-full border border-red-200 text-red-500 hover:bg-red-50 shadow-sm"><Trash2 className="w-2 h-2" /></button>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* KPIs Section */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="border-border/50 shadow-sm">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<DollarSign className="w-4 h-4 text-emerald-600" />
<h3 className="font-semibold text-primary-text text-sm">Average Rate</h3>
</div>
<div className="space-y-1">
<p className="text-2xl font-bold text-primary-text">{fmtCurrency(kpis.avg)}</p>
<p className="text-xs text-secondary-text">Across {kpis.total} positions active in book</p>
</div>
</CardContent>
</Card>
<Card className="border-border/50 shadow-sm">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<BarChart3 className="w-4 h-4 text-blue-600" />
<h3 className="font-semibold text-primary-text text-sm">Coverage</h3>
</div>
<div className="space-y-1">
<p className="text-2xl font-bold text-primary-text">{kpis.total} <span className="text-sm font-normal text-secondary-text">roles</span></p>
<p className="text-xs text-secondary-text">Total defined positions</p>
</div>
</CardContent>
</Card>
<Card className="border-border/50 shadow-sm">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<MapPin className="w-4 h-4 text-purple-600" />
<h3 className="font-semibold text-primary-text text-sm">Spread</h3>
</div>
<div className="space-y-1">
<p className="text-2xl font-bold text-primary-text">{fmtCurrency(kpis.min)} {fmtCurrency(kpis.max)}</p>
<p className="text-xs text-secondary-text">Rate range (Min - Max)</p>
</div>
</CardContent>
</Card>
</div>
{/* Filters - styled like the Search/Filter blocks */}
<div className="bg-muted/5 p-4 rounded-xl border border-border/50 flex flex-col md:flex-row gap-4 items-center justify-between">
<div className="flex items-center gap-4 flex-1 w-full overflow-x-auto">
<div className="flex items-center gap-2 text-secondary-text min-w-fit">
<Filter className="w-4 h-4" />
<span className="text-sm font-semibold uppercase tracking-wider">Filters</span>
</div>
{/* Categories */}
<div className="flex gap-2">
{["All", ...CATEGORIES].map((cat) => (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all whitespace-nowrap ${activeCategory === cat
? "bg-primary text-primary-foreground shadow-sm"
: "bg-white border border-border/50 text-secondary-text hover:bg-muted"
}`}
>
{cat}
</button>
))}
</div>
</div>
<div className="relative w-full md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search positions..."
className="pl-9 h-9 bg-white border-border/50"
/>
</div>
</div>
{/* Main Rates Table */}
<div>
{/* Section Header */}
<div className="flex items-center justify-between mb-4 px-1">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center border border-border/30 shadow-sm">
<span className="text-lg">💰</span>
</div>
<div>
<h3 className="font-bold text-primary-text">Rate Breakdown</h3>
<p className="text-xs text-muted-text">Detailed pricing for {pricebook}</p>
</div>
</div>
</div>
<Card className="border-border/50 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-muted/30 border-b border-border/50">
<tr className="text-muted-text text-[10px] font-bold uppercase tracking-wider">
<th className="px-6 py-4">Position</th>
<th className="px-6 py-4">Category</th>
<th className="px-6 py-4">Region</th>
<th className="px-6 py-4">Base Wage</th>
<th className="px-6 py-4">{pricebook} Rate</th>
<th className="px-6 py-4">OT 8h</th>
<th className="px-6 py-4">OT 12h</th>
<th className="px-6 py-4">Holiday</th>
<th className="px-6 py-4">Market Rate</th>
<th className="px-6 py-4">Comp. Score</th>
<th className="px-6 py-4 text-center">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border/20">
{filtered.map((r: any, idx) => {
const parsed = parseRoleName(r.position);
const competitive = competitivenessData?.find(
(c) => c.position === parsed.position,
);
const isEditing = editing === r.id;
return (
<tr
key={r.id || idx}
className={`transition-all hover:bg-muted/10 bg-card`}
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{/* Icon for position */}
<div className="w-8 h-8 rounded-lg bg-primary/5 flex items-center justify-center text-primary font-bold text-xs border border-primary/10">
{parsed.position.substring(0, 2).toUpperCase()}
</div>
<p className="font-bold text-primary-text text-sm">{parsed.position}</p>
</div>
</td>
<td className="px-6 py-4">
<Badge
variant="outline"
className="bg-muted/30 text-secondary-text border-border/50 text-[10px] font-bold px-2 py-0.5"
>
{r.category}
</Badge>
</td>
<td className="px-6 py-4">
<span className="text-xs text-secondary-text flex items-center gap-1 font-medium">
{r.region !== '—' && <MapPin className="w-3 h-3 text-muted-text" />}
{r.region}
</span>
</td>
<td className="px-6 py-4">
<span className="text-sm font-medium text-secondary-text">
{fmtCurrency(r.employee_wage)}
</span>
</td>
<td className="px-6 py-4">
{isEditing ? (
<input
type="number"
step="0.01"
defaultValue={r.proposedRate}
className="w-20 px-2 py-1 border border-primary rounded-md font-bold text-primary focus:outline-none text-xs"
onBlur={(e) => {
handleUpdateVendorRate({
id: r.id,
clientRate: parseFloat(e.target.value)
});
setEditing(null);
toast({ title: "Rate updated successfully" });
}}
autoFocus
/>
) : (
<span className="text-sm font-bold text-primary-text font-mono">
{fmtCurrency(r.proposedRate)}
</span>
)}
</td>
<td className="px-6 py-4">
{isEditing ? (
<input
type="number"
step="0.1"
defaultValue={r.overtime8Multiplier}
className="w-16 px-2 py-1 border border-primary rounded-md font-medium text-primary focus:outline-none text-xs"
onBlur={(e) => {
handleUpdateVendorRate({
id: r.id,
overtime8Multiplier: parseFloat(e.target.value)
});
}}
/>
) : (
<span className="text-xs font-medium text-secondary-text">{r.overtime8Multiplier}x</span>
)}
</td>
<td className="px-6 py-4">
{isEditing ? (
<input
type="number"
step="0.1"
defaultValue={r.overtime12Multiplier}
className="w-16 px-2 py-1 border border-primary rounded-md font-medium text-primary focus:outline-none text-xs"
onBlur={(e) => {
handleUpdateVendorRate({
id: r.id,
overtime12Multiplier: parseFloat(e.target.value)
});
}}
/>
) : (
<span className="text-xs font-medium text-secondary-text">{r.overtime12Multiplier}x</span>
)}
</td>
<td className="px-6 py-4">
{isEditing ? (
<input
type="number"
step="0.01"
defaultValue={r.holidayRate}
className="w-20 px-2 py-1 border border-primary rounded-md font-medium text-primary focus:outline-none text-xs"
onBlur={(e) => {
handleUpdateVendorRate({
id: r.id,
holidayRate: parseFloat(e.target.value)
});
}}
/>
) : (
<span className="text-xs font-medium text-secondary-text">{fmtCurrency(r.holidayRate)}</span>
)}
</td>
<td className="px-6 py-4">
{competitive ? (
<div className="flex flex-col">
<span className="text-xs font-medium text-primary-text">{fmtCurrency(competitive.marketRate)}</span>
<span className={`text-[10px] font-bold ${r.proposedRate < competitive.marketRate ? "text-emerald-600" : "text-red-600"}`}>
{r.proposedRate < competitive.marketRate ? "▼ Below" : "▲ Above"}
</span>
</div>
) : <span className="text-muted-text text-sm"></span>}
</td>
<td className="px-6 py-4">
{competitive ? (
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${competitive.status === "Highly Competitive" ? "bg-emerald-500" : "bg-blue-500"}`} />
<span className="text-xs font-medium text-primary-text">{competitive.score}/100</span>
</div>
) : <span className="text-muted-text text-sm"></span>}
</td>
<td className="px-6 py-4 text-center">
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(isEditing ? null : r.id)}
className={`${isEditing
? "bg-emerald-50 text-emerald-600 hover:bg-emerald-100"
: "text-muted-text hover:text-primary hover:bg-primary/5"
} h-8 w-8 p-0`}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
<RateCardModal
isOpen={showRateCardModal}
onClose={() => {
setShowRateCardModal(false);
setEditingRateCard(null);
}}
onSave={handleSaveRateCard}
editingCard={editingRateCard}
/>
</div>
</DashboardLayout>
);
}
// NOTE: Creating a dummy FilesIcon component to suppress errors if it's missing, or assumelucide import
const FilesIcon = FileText;
// The user asked to match InvoiceEditor design. InvoiceEditor is primarily a form/management view.
// I will ensure the VendorCompanyPricebookView is the primary export and fully fleshed out.
export default function ServiceRates() {
const { user } = useSelector((state: any) => state.auth);
const { data: vendorData } = useGetVendorByUserId({ userId: user?.uid || "" }, { enabled: !!user?.uid });
const vendorName = vendorData?.vendors[0]?.companyName || "Vendor";
return <VendorCompanyPricebookView vendorName={vendorName} />;
}

View File

@@ -0,0 +1,143 @@
import { Badge } from "@/common/components/ui/badge";
import { Button } from "@/common/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/common/components/ui/dailog";
import { Input } from "@/common/components/ui/input";
import { Label } from "@/common/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/common/components/ui/select";
import { useEffect, useState } from "react";
import { Briefcase } from "lucide-react";
interface RateCardModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (cardData: any) => void;
editingCard?: any;
}
export default function RateCardModal({
isOpen,
onClose,
onSave,
editingCard,
}: RateCardModalProps) {
const [formData, setFormData] = useState({
name: "",
baseBook: "FoodBuy",
discount: 0,
});
useEffect(() => {
if (editingCard) {
setFormData({
name: editingCard.name,
baseBook: editingCard.baseBook,
discount: editingCard.discount,
});
} else {
setFormData({
name: "",
baseBook: "FoodBuy",
discount: 0,
});
}
}, [editingCard, isOpen]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[425px] border-border/50 shadow-lg bg-card text-primary-text">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-primary-text">
<Briefcase className="w-5 h-5 text-primary" />
{editingCard ? "Edit Rate Card" : "New Rate Card"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-5 py-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-secondary-text text-xs uppercase font-bold tracking-wider">Rate Card Name</Label>
<Input
id="name"
placeholder="e.g. VIP Client 2025"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
required
className="bg-card border-border/50 focus:border-primary"
/>
</div>
<div className="space-y-2">
<Label htmlFor="baseBook" className="text-secondary-text text-xs uppercase font-bold tracking-wider">Base Pricebook</Label>
<Select
value={formData.baseBook}
onValueChange={(value: string) =>
setFormData({ ...formData, baseBook: value })
}
>
<SelectTrigger className="bg-card border-border/50 focus:border-primary">
<SelectValue placeholder="Select base book" />
</SelectTrigger>
<SelectContent>
<SelectItem value="FoodBuy">FoodBuy Enterprise</SelectItem>
<SelectItem value="Aramark">Aramark Standard</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-text">
Starting rates will be pulled from this master book
</p>
</div>
<div className="space-y-2">
<Label htmlFor="discount" className="text-secondary-text text-xs uppercase font-bold tracking-wider">Volume Discount (%)</Label>
<div className="flex items-center gap-3">
<Input
id="discount"
type="number"
min="0"
max="100"
step="0.1"
value={formData.discount}
onChange={(e) =>
setFormData({
...formData,
discount: parseFloat(e.target.value),
})
}
className="w-24 bg-card border-border/50 focus:border-primary font-mono text-right"
/>
<Badge variant="outline" className="text-[10px] font-bold text-emerald-600 bg-emerald-50 border-emerald-200">
Applied to all rates
</Badge>
</div>
</div>
</form>
<DialogFooter>
<Button variant="outline" onClick={onClose} className="border-border/50 text-secondary-text hover:text-primary-text">
Cancel
</Button>
<Button onClick={handleSubmit} type="submit" className="bg-primary text-primary-foreground hover:bg-primary/90">
{editingCard ? "Save Changes" : "Create Card"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -15,6 +15,9 @@ import AddStaff from './features/workforce/directory/AddStaff';
import ClientList from './features/business/clients/ClientList'; import ClientList from './features/business/clients/ClientList';
import EditClient from './features/business/clients/EditClient'; import EditClient from './features/business/clients/EditClient';
import AddClient from './features/business/clients/AddClient'; import AddClient from './features/business/clients/AddClient';
import ServiceRates from './features/business/rates/ServiceRates';
/** /**
* AppRoutes Component * AppRoutes Component
* Defines the main routing structure of the application. * Defines the main routing structure of the application.
@@ -87,6 +90,7 @@ const AppRoutes: React.FC = () => {
<Route path="/clients" element={<ClientList />} /> <Route path="/clients" element={<ClientList />} />
<Route path="/clients/:id/edit" element={<EditClient />} /> <Route path="/clients/:id/edit" element={<EditClient />} />
<Route path="/clients/add" element={<AddClient />} /> <Route path="/clients/add" element={<AddClient />} />
<Route path="/rates" element={<ServiceRates />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="*" element={<Navigate to="/login" replace />} />
</Routes> </Routes>