827 lines
34 KiB
TypeScript
827 lines
34 KiB
TypeScript
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,
|
||
useUpdateVendorRate,
|
||
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: updateVendorRate } = useUpdateVendorRate();
|
||
|
||
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 categories = vendorRates.reduce<string[]>((acc, r) => {
|
||
if (r.category) {
|
||
acc.push(String(r.category));
|
||
}
|
||
return acc;
|
||
}, []);
|
||
return Array.from(new Set(categories));
|
||
}, [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 || "");
|
||
|
||
// 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={`2025–2028 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);
|
||
if (!cardData) {
|
||
return null;
|
||
}
|
||
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} />;
|
||
}
|