geo/frontend/components/business/client-switcher.tsx

247 lines
8.4 KiB
TypeScript

"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface ClientInfo {
id: string;
name: string;
/** 行业/类型标签 */
type?: string;
/** 头像 URL 或首字母 */
avatar?: string;
/** 是否激活 */
isActive?: boolean;
}
export interface ClientSwitcherProps extends React.HTMLAttributes<HTMLDivElement> {
clients: ClientInfo[];
currentClientId?: string;
onClientChange?: (client: ClientInfo) => void;
placeholder?: string;
}
const ClientAvatar = ({ client, size = "sm" }: { client: ClientInfo; size?: "sm" | "md" }) => {
const sizeClass = size === "md" ? "h-8 w-8 text-sm" : "h-6 w-6 text-xs";
if (client.avatar && client.avatar.startsWith("http")) {
return (
<img
src={client.avatar}
alt={client.name}
className={cn("shrink-0 rounded-lg object-cover", sizeClass)}
/>
);
}
// generate color from name
const colors = [
"bg-primary/15 text-primary",
"bg-accent/15 text-accent",
"bg-purple-100 text-purple-600",
"bg-blue-100 text-blue-600",
"bg-pink-100 text-pink-600",
"bg-indigo-100 text-indigo-600",
];
const colorIdx = client.name.charCodeAt(0) % colors.length;
return (
<div
className={cn(
"shrink-0 flex items-center justify-center rounded-lg font-bold",
sizeClass,
colors[colorIdx]
)}
>
{client.avatar ?? client.name.slice(0, 1).toUpperCase()}
</div>
);
};
const ClientSwitcher = React.forwardRef<HTMLDivElement, ClientSwitcherProps>(
(
{
className,
clients,
currentClientId,
onClientChange,
placeholder = "选择客户",
...props
},
ref
) => {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const dropdownRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const currentClient = clients.find((c) => c.id === currentClientId);
const filtered = React.useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return clients;
return clients.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.type?.toLowerCase().includes(q)
);
}, [clients, search]);
// close on outside click
React.useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
// focus input when open
React.useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
const handleSelect = (client: ClientInfo) => {
onClientChange?.(client);
setOpen(false);
setSearch("");
};
return (
<div ref={ref} className={cn("relative", className)} {...props}>
{/* Trigger */}
<button
type="button"
onClick={() => setOpen((p) => !p)}
className={cn(
"flex w-full items-center gap-2.5 rounded-lg border border-border bg-background px-3 py-2 text-sm transition-all duration-200",
"hover:border-primary/40 hover:bg-muted/30",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
open && "border-primary/50 ring-2 ring-ring/20"
)}
>
{currentClient ? (
<>
<ClientAvatar client={currentClient} size="sm" />
<div className="flex-1 min-w-0 text-left">
<p className="text-sm font-semibold text-foreground truncate leading-none">
{currentClient.name}
</p>
{currentClient.type && (
<p className="text-xs text-muted-foreground mt-0.5 truncate leading-none">
{currentClient.type}
</p>
)}
</div>
</>
) : (
<span className="flex-1 text-left text-muted-foreground">{placeholder}</span>
)}
<svg
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200",
open && "rotate-180"
)}
viewBox="0 0 16 16"
fill="none"
>
<path
d="M4 6L8 10L12 6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{/* Dropdown */}
{open && (
<div
ref={dropdownRef}
className={cn(
"absolute left-0 top-full z-50 mt-1.5 w-full min-w-[220px] overflow-hidden rounded-xl border border-border bg-background shadow-lg",
"animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
)}
>
{/* Search */}
<div className="p-2 border-b border-border">
<div className="flex items-center gap-2 rounded-lg bg-muted px-3 py-1.5">
<svg className="h-3.5 w-3.5 text-muted-foreground shrink-0" viewBox="0 0 16 16" fill="none">
<circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M10.5 10.5L13 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<input
ref={inputRef}
type="text"
placeholder="搜索客户..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 14 14" fill="none">
<path d="M4 4L10 10M10 4L4 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
)}
</div>
</div>
{/* List */}
<div className="max-h-64 overflow-y-auto geo-scrollbar p-1.5">
{filtered.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
</div>
) : (
filtered.map((client) => {
const isSelected = client.id === currentClientId;
return (
<button
key={client.id}
type="button"
onClick={() => handleSelect(client)}
className={cn(
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm transition-colors duration-150",
isSelected
? "bg-primary/8 text-primary"
: "text-foreground hover:bg-muted"
)}
>
<ClientAvatar client={client} size="sm" />
<div className="flex-1 min-w-0 text-left">
<p className="font-medium truncate leading-none">{client.name}</p>
{client.type && (
<p className="text-xs text-muted-foreground mt-0.5 truncate leading-none">
{client.type}
</p>
)}
</div>
{isSelected && (
<svg className="h-4 w-4 shrink-0 text-primary" viewBox="0 0 16 16" fill="none">
<path d="M3 8L6.5 11.5L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
);
})
)}
</div>
</div>
)}
</div>
);
}
);
ClientSwitcher.displayName = "ClientSwitcher";
export { ClientSwitcher };