feat: Implement business client list view and add client
This commit is contained in:
@@ -13,6 +13,8 @@ const badgeVariants = cva(
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"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",
|
||||
},
|
||||
},
|
||||
|
||||
380
apps/web/src/features/business/clients/AddClient.tsx
Normal file
380
apps/web/src/features/business/clients/AddClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
280
apps/web/src/features/business/clients/ClientList.tsx
Normal file
280
apps/web/src/features/business/clients/ClientList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
apps/web/src/features/business/clients/EditClient.tsx
Normal file
8
apps/web/src/features/business/clients/EditClient.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
const EditClient = () => {
|
||||
return (
|
||||
<div>EditClient</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditClient
|
||||
@@ -12,7 +12,9 @@ import PublicLayout from './features/layouts/PublicLayout';
|
||||
import StaffList from './features/workforce/directory/StaffList';
|
||||
import EditStaff from './features/workforce/directory/EditStaff';
|
||||
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
|
||||
* Defines the main routing structure of the application.
|
||||
@@ -81,6 +83,10 @@ const AppRoutes: React.FC = () => {
|
||||
<Route path="/staff" element={<StaffList />} />
|
||||
<Route path="/staff/add" element={<AddStaff />} />
|
||||
<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 path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
|
||||
Reference in New Issue
Block a user