feat: Implement business client list view and add client

This commit is contained in:
dhinesh-m24
2026-02-04 14:04:26 +05:30
parent d3378e4822
commit d373fbe269
5 changed files with 677 additions and 1 deletions

View File

@@ -13,6 +13,8 @@ const badgeVariants = cva(
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
success:
"border-transparent bg-emerald-500 text-white hover:bg-emerald-500/80",
outline: "text-foreground", outline: "text-foreground",
}, },
}, },

View File

@@ -0,0 +1,380 @@
import { Button } from "@/common/components/ui/button";
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 { Textarea } from "@/common/components/ui/textarea";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import { ArrowLeft, Loader2, Save, X, Mail } from "lucide-react";
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { useSelector } from "react-redux";
import type { RootState } from "@/store/store";
import {
useCreateBusiness,
useCreateTeamHub
} from "@/dataconnect-generated/react";
import {
BusinessArea,
BusinessSector,
BusinessStatus,
BusinessRateGroup
} from "@/dataconnect-generated";
import { dataConnect } from "@/features/auth/firebase";
import { motion, AnimatePresence } from "framer-motion";
export default function AddClient() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useSelector((state: RootState) => state.auth);
const [showSnackbar, setShowSnackbar] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState("");
const [formData, setFormData] = useState({
businessName: "",
companyLogoUrl: "",
contactName: "",
phone: "",
email: "",
hubBuilding: "",
address: "",
city: "",
area: BusinessArea.BAY_AREA,
sector: BusinessSector.OTHER,
rateGroup: BusinessRateGroup.STANDARD,
status: BusinessStatus.ACTIVE,
notes: ""
});
const { mutateAsync: createBusiness, isPending: isCreatingBusiness } = useCreateBusiness(dataConnect);
const { mutateAsync: createHub, isPending: isCreatingHub } = useCreateTeamHub(dataConnect);
const handleChange = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user?.uid) return;
try {
// 1. Create the business record
const businessResult = await createBusiness({
businessName: formData.businessName,
contactName: formData.contactName,
userId: user.uid,
companyLogoUrl: formData.companyLogoUrl,
phone: formData.phone,
email: formData.email,
hubBuilding: formData.hubBuilding,
address: formData.address,
city: formData.city,
area: formData.area,
sector: formData.sector,
rateGroup: formData.rateGroup,
status: formData.status,
notes: formData.notes
});
const businessId = businessResult.business_insert.id;
// 2. Automatically create the client's first "hub" or location
await createHub({
teamId: businessId,
hubName: `${formData.businessName} - Main Hub`,
address: formData.address || "Main Office",
city: formData.city,
isActive: true
});
// 3. Show snackbar for welcome email
setSnackbarMessage(`Welcome email sent to ${formData.contactName} (${formData.email})`);
setShowSnackbar(true);
// Invalidate queries and navigate after a delay to show snackbar
queryClient.invalidateQueries({ queryKey: ['businesses'] });
setTimeout(() => {
navigate("/clients");
}, 3000);
} catch (error) {
console.error("Error creating client partnership:", error);
setSnackbarMessage("Failed to create client partnership. Please try again.");
setShowSnackbar(true);
}
};
const isPending = isCreatingBusiness || isCreatingHub;
return (
<DashboardLayout
title="Register Business"
subtitle="Initialize a new client partnership and hub location."
actions={
<Button
variant="outline"
onClick={() => navigate("/clients")}
leadingIcon={<ArrowLeft />}
>
Back to Directory
</Button>
}
>
<div className=" mx-auto pb-20 relative">
<form onSubmit={handleSubmit}>
<div className="py-8 space-y-8">
{/* Business Name & Company Logo */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="businessName">
Business Name <span className="text-red-500">*</span>
</Label>
<Input
id="businessName"
value={formData.businessName}
onChange={(e) => handleChange('businessName', e.target.value)}
placeholder="Enter business name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="companyLogoUrl">
Company Logo URL
</Label>
<Input
id="companyLogoUrl"
value={formData.companyLogoUrl}
onChange={(e) => handleChange('companyLogoUrl', e.target.value)}
placeholder="https://example.com/logo.png"
/>
<p className="text-xs text-muted-text">Optional: URL to company logo image</p>
</div>
</div>
{/* Primary Contact */}
<div className="space-y-2">
<Label htmlFor="contactName">
Primary Contact <span className="text-red-500">*</span>
</Label>
<Input
id="contactName"
value={formData.contactName}
onChange={(e) => handleChange('contactName', e.target.value)}
placeholder="Contact name"
required
/>
</div>
{/* Contact Number & Email */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="phone">
Contact Number
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
placeholder="(555) 123-4567"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">
Email <span className="text-red-500">*</span>
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
placeholder="business@example.com"
required
/>
</div>
</div>
{/* Hub / Building */}
<div className="space-y-2">
<Label htmlFor="hubBuilding">
Hub / Building
</Label>
<Input
id="hubBuilding"
value={formData.hubBuilding}
onChange={(e) => handleChange('hubBuilding', e.target.value)}
placeholder="Building name or location"
/>
</div>
{/* Billing Address */}
<div className="space-y-2">
<Label htmlFor="address">
Billing Address <span className="text-red-500">*</span>
</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
placeholder="Street address"
required
/>
</div>
{/* City & Area */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="city">
City <span className="text-red-500">*</span>
</Label>
<Input
id="city"
value={formData.city}
onChange={(e) => handleChange('city', e.target.value)}
placeholder="City"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="area">
Area
</Label>
<Select value={formData.area} onValueChange={(value : any) => handleChange('area', value)}>
<SelectTrigger>
<SelectValue placeholder="Select area" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BusinessArea.BAY_AREA}>Bay Area</SelectItem>
<SelectItem value={BusinessArea.SOUTHERN_CALIFORNIA}>Southern California</SelectItem>
<SelectItem value={BusinessArea.NORTHERN_CALIFORNIA}>Northern California</SelectItem>
<SelectItem value={BusinessArea.CENTRAL_VALLEY}>Central Valley</SelectItem>
<SelectItem value={BusinessArea.OTHER}>Other</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Sector & Rate Group */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="sector">
Industry / Sector <span className="text-red-500">*</span>
</Label>
<Select value={formData.sector} onValueChange={(value :any) => handleChange('sector', value)}>
<SelectTrigger>
<SelectValue placeholder="Select sector" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BusinessSector.BON_APPETIT}>Bon Appétit</SelectItem>
<SelectItem value={BusinessSector.EUREST}>Eurest</SelectItem>
<SelectItem value={BusinessSector.ARAMARK}>Aramark</SelectItem>
<SelectItem value={BusinessSector.EPICUREAN_GROUP}>Epicurean Group</SelectItem>
<SelectItem value={BusinessSector.CHARTWELLS}>Chartwells</SelectItem>
<SelectItem value={BusinessSector.OTHER}>Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rateGroup">
Rate Group <span className="text-red-500">*</span>
</Label>
<Select value={formData.rateGroup} onValueChange={(value : any) => handleChange('rateGroup', value)} required>
<SelectTrigger>
<SelectValue placeholder="Select pricing tier" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BusinessRateGroup.STANDARD}>Standard</SelectItem>
<SelectItem value={BusinessRateGroup.PREMIUM}>Premium</SelectItem>
<SelectItem value={BusinessRateGroup.ENTERPRISE}>Enterprise</SelectItem>
<SelectItem value={BusinessRateGroup.CUSTOM}>Custom</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Status */}
<div className="space-y-2">
<Label htmlFor="status">
Status
</Label>
<Select value={formData.status} onValueChange={(value : any) => handleChange('status', value)}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BusinessStatus.ACTIVE}>Active</SelectItem>
<SelectItem value={BusinessStatus.INACTIVE}>Inactive</SelectItem>
<SelectItem value={BusinessStatus.PENDING}>Pending</SelectItem>
</SelectContent>
</Select>
</div>
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
value={formData.notes}
onChange={(e) => handleChange('notes', e.target.value)}
rows={4}
placeholder="Additional notes about this business..."
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-8">
<Button
type="button"
variant="outline"
onClick={() => navigate("/clients")}
>
Cancel
</Button>
<Button
type="submit"
disabled={isPending}
leadingIcon={isPending ? <Loader2 className="animate-spin" /> : <Save />}
>
{isPending ? "Initializing Partnership..." : "Create Business Client"}
</Button>
</div>
</form>
{/* Snackbar Notification */}
<AnimatePresence>
{showSnackbar && (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 bg-slate-900 text-white px-6 py-4 rounded-2xl shadow-2xl border border-white/10 min-w-[320px]"
>
<div className="p-2 bg-emerald-500/20 rounded-lg text-emerald-400">
<Mail size={18} />
</div>
<p className="text-sm font-bold flex-1">{snackbarMessage}</p>
<button
onClick={() => setShowSnackbar(false)}
className="p-1 hover:bg-white/10 rounded-full transition-colors"
>
<X size={16} />
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,280 @@
import { Button } from "@/common/components/ui/button";
import { Card, CardContent } from "@/common/components/ui/card";
import { Input } from "@/common/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/common/components/ui/select";
import { Badge } from "@/common/components/ui/badge";
import DashboardLayout from "@/features/layouts/DashboardLayout";
import {
Building2,
Plus,
Search,
ExternalLink,
Layers,
Activity
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import type { RootState } from "@/store/store";
import { useListBusinesses, useListTeamHubs, useListOrders } from "@/dataconnect-generated/react";
import { dataConnect } from "@/features/auth/firebase";
import { format } from "date-fns";
export default function ClientList() {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [industryFilter, setIndustryFilter] = useState("all");
const { user } = useSelector((state: RootState) => state.auth);
const isAdmin = user?.userRole === 'admin' || user?.userRole === 'ADMIN';
const { data: businessData, isLoading: loadingBusinesses } = useListBusinesses(dataConnect);
const { data: hubData, isLoading: loadingHubs } = useListTeamHubs(dataConnect);
const { data: orderData, isLoading: loadingOrders } = useListOrders(dataConnect);
const isLoading = loadingBusinesses || loadingHubs || loadingOrders;
const businesses = businessData?.businesses || [];
const hubs = hubData?.teamHubs || [];
const orders = orderData?.orders || [];
const industries = useMemo(() => {
return [...new Set(businesses.map(b => b.sector).filter(Boolean))];
}, [businesses]);
const processedClients = useMemo(() => {
return businesses.map(business => {
const businessHubs = hubs.filter(h => h.teamId === business.id);
const businessOrders = orders.filter(o => o.businessId === business.id);
const lastOrder = businessOrders.length > 0
? [...businessOrders].sort((a, b) => {
const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return timeB - timeA;
})[0]
: null;
return {
...business,
hubCount: businessHubs.length,
lastOrderDate: lastOrder ? lastOrder.createdAt : null
};
});
}, [businesses, hubs, orders]);
const filteredClients = useMemo(() => {
return processedClients.filter(client => {
const matchesSearch = client.businessName?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "all" || client.status === statusFilter;
const matchesIndustry = industryFilter === "all" || client.sector === industryFilter;
return matchesSearch && matchesStatus && matchesIndustry;
});
}, [processedClients, searchTerm, statusFilter, industryFilter]);
if (!isAdmin) {
return (
<DashboardLayout title="Access Denied" subtitle="Unauthorized Access">
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center">
<div className="w-16 h-16 bg-destructive/10 rounded-full flex items-center justify-center text-destructive mb-4">
<Activity className="w-8 h-8" />
</div>
<h2 className="text-2xl font-bold">Restricted Access</h2>
<p className="text-muted-foreground mt-2 max-w-sm">Only administrators are authorized to view and manage business client records.</p>
<Button onClick={() => navigate("/")} variant="outline" className="mt-6 rounded-xl font-bold">
Return to Dashboard
</Button>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout
title="Business Clients"
subtitle="Manage and monitor all business accounts"
actions={
<Button
onClick={() => navigate("/clients/add")}
leadingIcon={<Plus />}
>
Add Client
</Button>
}
>
<div className="space-y-6">
{/* KPI Summary */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-primary/10 rounded-xl text-primary">
<Building2 className="w-6 h-6" />
</div>
<div>
<p className="text-sm text-muted-foreground font-medium">Total Clients</p>
<p className="text-2xl font-bold">{businesses.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-emerald-500/10 rounded-xl text-emerald-600">
<Activity className="w-6 h-6" />
</div>
<div>
<p className="text-sm text-muted-foreground font-medium">Active Hubs</p>
<p className="text-2xl font-bold">{hubs.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-blue-500/10 rounded-xl text-blue-600">
<Layers className="w-6 h-6" />
</div>
<div>
<p className="text-sm text-muted-foreground font-medium">Total Orders</p>
<p className="text-2xl font-bold">{orders.length}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex flex-col md:flex-row gap-4 items-center">
<div className="relative flex-1 w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search by business name..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="PENDING">Pending</SelectItem>
<SelectItem value="INACTIVE">Inactive</SelectItem>
</SelectContent>
</Select>
<Select value={industryFilter} onValueChange={setIndustryFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Industry" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Industries</SelectItem>
{industries.map(industry => (
<SelectItem key={industry} value={industry}>{industry}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Table View */}
<div className="bg-card/50 backdrop-blur-sm border border-border rounded-2xl overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Business Name</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground text-center">Logo</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Industry</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Status</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground text-center">Hubs</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground">Last Order</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-wider text-muted-foreground text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{isLoading ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-muted-foreground font-medium">Loading clients...</p>
</div>
</td>
</tr>
) : filteredClients.length > 0 ? (
filteredClients.map((client) => (
<tr
key={client.id}
className="hover:bg-muted/40 cursor-pointer transition-colors group"
onClick={() => navigate(`/clients/${client.id}/edit`)}
>
<td className="px-6 py-4">
<div className="font-bold text-foreground group-hover:text-primary transition-colors">
{client.businessName}
</div>
<div className="text-xs text-muted-foreground">{client.email}</div>
</td>
<td className="px-6 py-4">
<div className="flex justify-center">
<div className="w-10 h-10 rounded-lg bg-muted border border-border overflow-hidden flex items-center justify-center">
{client.companyLogoUrl ? (
<img src={client.companyLogoUrl} alt={client.businessName} className="w-full h-full object-cover" />
) : (
<Building2 className="w-5 h-5 text-muted-foreground" />
)}
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm font-medium">{client.sector || 'N/A'}</div>
</td>
<td className="px-6 py-4">
<Badge variant={client.status === 'ACTIVE' ? 'success' : client.status === 'INACTIVE' ? 'destructive' : 'secondary'} className="font-bold uppercase text-[10px]">
{client.status?.toLowerCase() || 'PENDING'}
</Badge>
</td>
<td className="px-6 py-4 text-center">
<div className="text-sm font-bold bg-muted/50 w-8 h-8 rounded-full flex items-center justify-center mx-auto border border-border">
{client.hubCount}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm font-medium">
{client.lastOrderDate ? format(new Date(client.lastOrderDate), 'MMM d, yyyy') : 'No orders'}
</div>
</td>
<td className="px-6 py-4 text-right">
<Button variant="ghost" size="sm" className="rounded-lg h-8 w-8 p-0">
<ExternalLink className="w-4 h-4" />
</Button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={7} className="px-6 py-12 text-center">
<p className="text-muted-foreground">No business clients found matching your search.</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,8 @@
const EditClient = () => {
return (
<div>EditClient</div>
)
}
export default EditClient

View File

@@ -12,7 +12,9 @@ import PublicLayout from './features/layouts/PublicLayout';
import StaffList from './features/workforce/directory/StaffList'; import StaffList from './features/workforce/directory/StaffList';
import EditStaff from './features/workforce/directory/EditStaff'; import EditStaff from './features/workforce/directory/EditStaff';
import AddStaff from './features/workforce/directory/AddStaff'; 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';
/** /**
* AppRoutes Component * AppRoutes Component
* Defines the main routing structure of the application. * Defines the main routing structure of the application.
@@ -81,6 +83,10 @@ const AppRoutes: React.FC = () => {
<Route path="/staff" element={<StaffList />} /> <Route path="/staff" element={<StaffList />} />
<Route path="/staff/add" element={<AddStaff />} /> <Route path="/staff/add" element={<AddStaff />} />
<Route path="/staff/:id/edit" element={<EditStaff />} /> <Route path="/staff/:id/edit" element={<EditStaff />} />
{/* Business Routes */}
<Route path="/clients" element={<ClientList />} />
<Route path="/clients/:id/edit" element={<EditClient />} />
<Route path="/clients/add" element={<AddClient />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="*" element={<Navigate to="/login" replace />} />
</Routes> </Routes>