- Global Command Palette: Ctrl+K search across clients, pages, and actions - Arrow key navigation, grouped results (Pages/Clients/Actions) - Keyboard hints in footer - Dark mode: full theme toggle (light/dark/system) with localStorage - Theme toggle in header bar - Dark mode applied to Layout, Dashboard, Clients, ClientDetail, Login, Modal - Tailwind v4 @custom-variant for class-based dark mode - Pinned/Favorite clients: star clients for quick dashboard access - Pin button on client detail page and dashboard recent clients - Pinned clients grid on dashboard - Uses localStorage (no backend changes needed) - Search bar trigger in header with ⌘K shortcut hint
338 lines
16 KiB
TypeScript
338 lines
16 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
|
import { useClientsStore } from '@/stores/clients';
|
|
import { api } from '@/lib/api';
|
|
import type { Event, Email, ActivityItem } from '@/types';
|
|
import {
|
|
ArrowLeft, Edit3, Trash2, Phone, Mail, MapPin, Building2,
|
|
Briefcase, Gift, Heart, Star, Users, Calendar, Send,
|
|
CheckCircle2, Sparkles, Clock, Activity, FileText, UserPlus, RefreshCw, Pin,
|
|
} from 'lucide-react';
|
|
import { usePinnedClients } from '@/hooks/usePinnedClients';
|
|
import { cn, formatDate, getRelativeTime, getInitials } from '@/lib/utils';
|
|
import Badge, { EventTypeBadge, EmailStatusBadge } from '@/components/Badge';
|
|
import { PageLoader } from '@/components/LoadingSpinner';
|
|
import Modal from '@/components/Modal';
|
|
import ClientForm from '@/components/ClientForm';
|
|
import EmailComposeModal from '@/components/EmailComposeModal';
|
|
|
|
export default function ClientDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const { selectedClient, isLoading, fetchClient, updateClient, deleteClient, markContacted } = useClientsStore();
|
|
const [events, setEvents] = useState<Event[]>([]);
|
|
const [emails, setEmails] = useState<Email[]>([]);
|
|
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
|
const [activeTab, setActiveTab] = useState<'info' | 'activity' | 'events' | 'emails'>('info');
|
|
const [showEdit, setShowEdit] = useState(false);
|
|
const [showCompose, setShowCompose] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const { togglePin, isPinned } = usePinnedClients();
|
|
|
|
useEffect(() => {
|
|
if (id) {
|
|
fetchClient(id);
|
|
api.getEvents({ clientId: id }).then(setEvents).catch(() => {});
|
|
api.getEmails({ clientId: id }).then(setEmails).catch(() => {});
|
|
api.getClientActivity(id).then(setActivities).catch(() => {});
|
|
}
|
|
}, [id, fetchClient]);
|
|
|
|
if (isLoading || !selectedClient) return <PageLoader />;
|
|
|
|
const client = selectedClient;
|
|
|
|
const handleDelete = async () => {
|
|
if (!confirm('Delete this client? This cannot be undone.')) return;
|
|
setDeleting(true);
|
|
try {
|
|
await deleteClient(client.id);
|
|
navigate('/clients');
|
|
} catch {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
const handleMarkContacted = async () => {
|
|
await markContacted(client.id);
|
|
};
|
|
|
|
const handleUpdate = async (data: any) => {
|
|
await updateClient(client.id, data);
|
|
setShowEdit(false);
|
|
};
|
|
|
|
const tabs: { key: 'info' | 'activity' | 'events' | 'emails'; label: string; count?: number; icon: typeof Users }[] = [
|
|
{ key: 'info', label: 'Info', icon: Users },
|
|
{ key: 'activity', label: 'Timeline', count: activities.length, icon: Activity },
|
|
{ key: 'events', label: 'Events', count: events.length, icon: Calendar },
|
|
{ key: 'emails', label: 'Emails', count: emails.length, icon: Mail },
|
|
];
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row items-start gap-4">
|
|
<button onClick={() => navigate('/clients')} className="mt-1 p-2 rounded-lg text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-slate-600 dark:hover:text-slate-300 transition-colors">
|
|
<ArrowLeft className="w-5 h-5" />
|
|
</button>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-16 h-16 bg-blue-100 text-blue-700 rounded-2xl flex items-center justify-center text-xl font-bold flex-shrink-0">
|
|
{getInitials(client.firstName, client.lastName)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
|
{client.firstName} {client.lastName}
|
|
</h1>
|
|
{client.company && (
|
|
<p className="text-slate-500 dark:text-slate-400">
|
|
{client.role ? `${client.role} at ` : ''}{client.company}
|
|
</p>
|
|
)}
|
|
{client.tags && client.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
{client.tags.map((tag) => <Badge key={tag} color="blue">{tag}</Badge>)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button onClick={handleMarkContacted} className="flex items-center gap-2 px-3 py-2 bg-emerald-50 text-emerald-700 rounded-lg text-sm font-medium hover:bg-emerald-100 transition-colors">
|
|
<CheckCircle2 className="w-4 h-4" />
|
|
<span className="hidden sm:inline">Contacted</span>
|
|
</button>
|
|
<button onClick={() => setShowCompose(true)} className="flex items-center gap-2 px-3 py-2 bg-blue-50 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-100 transition-colors">
|
|
<Sparkles className="w-4 h-4" />
|
|
<span className="hidden sm:inline">Generate Email</span>
|
|
</button>
|
|
<button
|
|
onClick={() => togglePin(client.id)}
|
|
className={`p-2 rounded-lg transition-colors ${isPinned(client.id) ? 'text-amber-500 bg-amber-50 dark:bg-amber-900/30' : 'text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-amber-500'}`}
|
|
title={isPinned(client.id) ? 'Unpin from dashboard' : 'Pin to dashboard'}
|
|
>
|
|
<Star className={`w-4 h-4 ${isPinned(client.id) ? 'fill-amber-500' : ''}`} />
|
|
</button>
|
|
<button onClick={() => setShowEdit(true)} className="p-2 rounded-lg text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-slate-600 dark:hover:text-slate-300 transition-colors">
|
|
<Edit3 className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={handleDelete} disabled={deleting} className="p-2 rounded-lg text-slate-400 hover:bg-red-50 hover:text-red-600 transition-colors">
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="border-b border-slate-200 dark:border-slate-700">
|
|
<div className="flex gap-6">
|
|
{tabs.map(({ key, label, count, icon: Icon }) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => setActiveTab(key)}
|
|
className={cn(
|
|
'flex items-center gap-2 pb-3 border-b-2 text-sm font-medium transition-colors',
|
|
activeTab === key
|
|
? 'border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400'
|
|
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'
|
|
)}
|
|
>
|
|
<Icon className="w-4 h-4" />
|
|
{label}
|
|
{count !== undefined && count > 0 && (
|
|
<span className="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded-full text-xs">{count}</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
{activeTab === 'info' && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Contact Info */}
|
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 space-y-4">
|
|
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Contact Information</h3>
|
|
{client.email && (
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<Mail className="w-4 h-4 text-slate-400" />
|
|
<a href={`mailto:${client.email}`} className="text-blue-600 hover:underline">{client.email}</a>
|
|
</div>
|
|
)}
|
|
{client.phone && (
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<Phone className="w-4 h-4 text-slate-400" />
|
|
<span className="text-slate-700">{client.phone}</span>
|
|
</div>
|
|
)}
|
|
{(client.street || client.city) && (
|
|
<div className="flex items-start gap-3 text-sm">
|
|
<MapPin className="w-4 h-4 text-slate-400 mt-0.5" />
|
|
<span className="text-slate-700">
|
|
{[client.street, client.city, client.state, client.zip].filter(Boolean).join(', ')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{client.company && (
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<Building2 className="w-4 h-4 text-slate-400" />
|
|
<span className="text-slate-700">{client.company}</span>
|
|
</div>
|
|
)}
|
|
{client.industry && (
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<Briefcase className="w-4 h-4 text-slate-400" />
|
|
<span className="text-slate-700">{client.industry}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<Clock className="w-4 h-4 text-slate-400" />
|
|
<span className="text-slate-500">Last contacted: {getRelativeTime(client.lastContacted)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Personal */}
|
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 space-y-4">
|
|
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Personal Details</h3>
|
|
{client.birthday && (
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<Gift className="w-4 h-4 text-pink-500" />
|
|
<span className="text-slate-700">Birthday: {formatDate(client.birthday)}</span>
|
|
</div>
|
|
)}
|
|
{client.anniversary && (
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<Heart className="w-4 h-4 text-purple-500" />
|
|
<span className="text-slate-700">Anniversary: {formatDate(client.anniversary)}</span>
|
|
</div>
|
|
)}
|
|
{client.interests && client.interests.length > 0 && (
|
|
<div>
|
|
<div className="flex items-center gap-2 text-sm text-slate-500 mb-2">
|
|
<Star className="w-4 h-4" />
|
|
Interests
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{client.interests.map((i) => <Badge key={i} color="green">{i}</Badge>)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{client.family?.spouse && (
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<Users className="w-4 h-4 text-slate-400" />
|
|
<span className="text-slate-700">Spouse: {client.family.spouse}</span>
|
|
</div>
|
|
)}
|
|
{client.family?.children && client.family.children.length > 0 && (
|
|
<div className="text-sm text-slate-700 ml-7">
|
|
Children: {client.family.children.join(', ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
{client.notes && (
|
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 md:col-span-2">
|
|
<h3 className="font-semibold text-slate-900 dark:text-slate-100 mb-2">Notes</h3>
|
|
<p className="text-sm text-slate-700 dark:text-slate-300 whitespace-pre-wrap">{client.notes}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'activity' && (
|
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">
|
|
{activities.length === 0 ? (
|
|
<p className="px-5 py-8 text-center text-sm text-slate-400">No activity recorded yet</p>
|
|
) : (
|
|
<div className="relative">
|
|
{activities.map((item, index) => {
|
|
const iconMap: Record<string, { icon: typeof Mail; color: string; bg: string }> = {
|
|
email_sent: { icon: Send, color: 'text-emerald-600', bg: 'bg-emerald-100' },
|
|
email_drafted: { icon: FileText, color: 'text-amber-600', bg: 'bg-amber-100' },
|
|
event_created: { icon: Calendar, color: 'text-blue-600', bg: 'bg-blue-100' },
|
|
client_contacted: { icon: CheckCircle2, color: 'text-emerald-600', bg: 'bg-emerald-100' },
|
|
client_created: { icon: UserPlus, color: 'text-purple-600', bg: 'bg-purple-100' },
|
|
client_updated: { icon: RefreshCw, color: 'text-slate-600', bg: 'bg-slate-100' },
|
|
};
|
|
const { icon: Icon, color, bg } = iconMap[item.type] || iconMap.client_updated;
|
|
|
|
return (
|
|
<div key={item.id} className="flex gap-4 px-5 py-4 relative">
|
|
{/* Timeline line */}
|
|
{index < activities.length - 1 && (
|
|
<div className="absolute left-[2.15rem] top-14 bottom-0 w-px bg-slate-200" />
|
|
)}
|
|
{/* Icon */}
|
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${bg} relative z-10`}>
|
|
<Icon className={`w-4 h-4 ${color}`} />
|
|
</div>
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-slate-900">{item.title}</p>
|
|
{item.description && (
|
|
<p className="text-xs text-slate-500 mt-0.5 line-clamp-2">{item.description}</p>
|
|
)}
|
|
<p className="text-xs text-slate-400 mt-1">{formatDate(item.date)}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'events' && (
|
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl divide-y divide-slate-100 dark:divide-slate-700">
|
|
{events.length === 0 ? (
|
|
<p className="px-5 py-8 text-center text-sm text-slate-400">No events for this client</p>
|
|
) : (
|
|
events.map((event) => (
|
|
<div key={event.id} className="flex items-center gap-3 px-5 py-4">
|
|
<EventTypeBadge type={event.type} />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-slate-900">{event.title}</p>
|
|
<p className="text-xs text-slate-500">{formatDate(event.date)} {event.recurring && '· Recurring'}</p>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'emails' && (
|
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl divide-y divide-slate-100 dark:divide-slate-700">
|
|
{emails.length === 0 ? (
|
|
<p className="px-5 py-8 text-center text-sm text-slate-400">No emails for this client</p>
|
|
) : (
|
|
emails.map((email) => (
|
|
<Link key={email.id} to={`/emails?id=${email.id}`} className="flex items-center gap-3 px-5 py-4 hover:bg-slate-50 transition-colors">
|
|
<EmailStatusBadge status={email.status} />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-slate-900 truncate">{email.subject}</p>
|
|
<p className="text-xs text-slate-500">{formatDate(email.createdAt)}</p>
|
|
</div>
|
|
</Link>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit Modal */}
|
|
<Modal isOpen={showEdit} onClose={() => setShowEdit(false)} title="Edit Client" size="lg">
|
|
<ClientForm initialData={client} onSubmit={handleUpdate} />
|
|
</Modal>
|
|
|
|
{/* Email Compose Modal */}
|
|
<EmailComposeModal
|
|
isOpen={showCompose}
|
|
onClose={() => setShowCompose(false)}
|
|
clientId={client.id}
|
|
clientName={`${client.firstName} ${client.lastName}`}
|
|
onGenerated={(email) => setEmails((prev) => [email, ...prev])}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|