fix: Verify functionality of Add Client and Client List
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@firebase/analytics": "^0.10.19",
|
||||
"@firebase/data-connect": "^0.3.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
|
||||
3
apps/web/pnpm-lock.yaml
generated
3
apps/web/pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
||||
'@firebase/data-connect':
|
||||
specifier: ^0.3.12
|
||||
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':
|
||||
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)
|
||||
|
||||
119
apps/web/src/common/components/ui/dailog.tsx
Normal file
119
apps/web/src/common/components/ui/dailog.tsx
Normal 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,
|
||||
};
|
||||
25
apps/web/src/common/components/ui/use-toast.tsx
Normal file
25
apps/web/src/common/components/ui/use-toast.tsx
Normal 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 };
|
||||
};
|
||||
@@ -18,13 +18,14 @@ import { useSelector } from "react-redux";
|
||||
import type { RootState } from "@/store/store";
|
||||
import {
|
||||
useCreateBusiness,
|
||||
useCreateTeamHub
|
||||
useCreateTeamHub,
|
||||
useCreateTeam
|
||||
} from "@/dataconnect-generated/react";
|
||||
import {
|
||||
BusinessArea,
|
||||
BusinessSector,
|
||||
BusinessStatus,
|
||||
BusinessRateGroup
|
||||
BusinessRateGroup,
|
||||
} from "@/dataconnect-generated";
|
||||
import { dataConnect } from "@/features/auth/firebase";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
@@ -55,6 +56,7 @@ export default function AddClient() {
|
||||
|
||||
const { mutateAsync: createBusiness, isPending: isCreatingBusiness } = useCreateBusiness(dataConnect);
|
||||
const { mutateAsync: createHub, isPending: isCreatingHub } = useCreateTeamHub(dataConnect);
|
||||
const { mutateAsync: createTeam, isPending: isCreatingTeam } = useCreateTeam(dataConnect);
|
||||
|
||||
const handleChange = (field: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
@@ -82,12 +84,35 @@ export default function AddClient() {
|
||||
status: formData.status,
|
||||
notes: formData.notes
|
||||
});
|
||||
console.log("Business created:", businessResult);
|
||||
|
||||
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
|
||||
await createHub({
|
||||
teamId: businessId,
|
||||
teamId: teamId,
|
||||
hubName: `${formData.businessName} - Main Hub`,
|
||||
address: formData.address || "Main Office",
|
||||
city: formData.city,
|
||||
|
||||
@@ -172,7 +172,7 @@ export default function ClientList() {
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||
<SelectItem value="PENDING">Pending</SelectItem>
|
||||
<SelectItem value="INACTIVE">Inactive</SelectItem>
|
||||
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={industryFilter} onValueChange={setIndustryFilter}>
|
||||
|
||||
823
apps/web/src/features/business/rates/ServiceRates.tsx
Normal file
823
apps/web/src/features/business/rates/ServiceRates.tsx
Normal 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={`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);
|
||||
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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,9 @@ import AddStaff from './features/workforce/directory/AddStaff';
|
||||
import ClientList from './features/business/clients/ClientList';
|
||||
import EditClient from './features/business/clients/EditClient';
|
||||
import AddClient from './features/business/clients/AddClient';
|
||||
import ServiceRates from './features/business/rates/ServiceRates';
|
||||
|
||||
|
||||
/**
|
||||
* AppRoutes Component
|
||||
* Defines the main routing structure of the application.
|
||||
@@ -87,6 +90,7 @@ const AppRoutes: React.FC = () => {
|
||||
<Route path="/clients" element={<ClientList />} />
|
||||
<Route path="/clients/:id/edit" element={<EditClient />} />
|
||||
<Route path="/clients/add" element={<AddClient />} />
|
||||
<Route path="/rates" element={<ServiceRates />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
|
||||
Reference in New Issue
Block a user