feat: initial SPA frontend for network app
This commit is contained in:
180
src/pages/ClientsPage.tsx
Normal file
180
src/pages/ClientsPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useClientsStore } from '@/stores/clients';
|
||||
import { Search, Plus, Users, X } from 'lucide-react';
|
||||
import { cn, getRelativeTime, getInitials } from '@/lib/utils';
|
||||
import Badge from '@/components/Badge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
import Modal from '@/components/Modal';
|
||||
import ClientForm from '@/components/ClientForm';
|
||||
|
||||
export default function ClientsPage() {
|
||||
const location = useLocation();
|
||||
const { clients, isLoading, searchQuery, selectedTag, setSearchQuery, setSelectedTag, fetchClients, createClient } = useClientsStore();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClients();
|
||||
}, [fetchClients]);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.state?.openCreate) {
|
||||
setShowCreate(true);
|
||||
window.history.replaceState({}, '');
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
// Client-side filtering for immediate feedback
|
||||
const filteredClients = useMemo(() => {
|
||||
let result = clients;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(c) =>
|
||||
`${c.firstName} ${c.lastName}`.toLowerCase().includes(q) ||
|
||||
c.email?.toLowerCase().includes(q) ||
|
||||
c.company?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if (selectedTag) {
|
||||
result = result.filter((c) => c.tags?.includes(selectedTag));
|
||||
}
|
||||
return result;
|
||||
}, [clients, searchQuery, selectedTag]);
|
||||
|
||||
// All unique tags
|
||||
const allTags = useMemo(() => {
|
||||
const tags = new Set<string>();
|
||||
clients.forEach((c) => c.tags?.forEach((t) => tags.add(t)));
|
||||
return Array.from(tags).sort();
|
||||
}, [clients]);
|
||||
|
||||
const handleCreate = async (data: any) => {
|
||||
setCreating(true);
|
||||
try {
|
||||
await createClient(data);
|
||||
setShowCreate(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && clients.length === 0) return <PageLoader />;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-5 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Clients</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">{clients.length} contacts in your network</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Client
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search + Tags */}
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search clients..."
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
color="blue"
|
||||
active={selectedTag === tag}
|
||||
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{selectedTag && (
|
||||
<button onClick={() => setSelectedTag(null)} className="text-xs text-slate-500 hover:text-slate-700 ml-1">
|
||||
Clear filter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Client Grid */}
|
||||
{filteredClients.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={searchQuery || selectedTag ? 'No matches found' : 'No clients yet'}
|
||||
description={searchQuery || selectedTag ? 'Try adjusting your search or filters' : 'Add your first client to get started'}
|
||||
action={!searchQuery && !selectedTag ? { label: 'Add Client', onClick: () => setShowCreate(true) } : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredClients.map((client) => (
|
||||
<Link
|
||||
key={client.id}
|
||||
to={`/clients/${client.id}`}
|
||||
className="bg-white border border-slate-200 rounded-xl p-5 hover:shadow-md hover:border-slate-300 transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-11 h-11 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0 group-hover:bg-blue-200 transition-colors">
|
||||
{getInitials(client.firstName, client.lastName)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-slate-900 truncate">
|
||||
{client.firstName} {client.lastName}
|
||||
</h3>
|
||||
{client.company && (
|
||||
<p className="text-sm text-slate-500 truncate">{client.role ? `${client.role} at ` : ''}{client.company}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{client.tags && client.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
{client.tags.slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-full text-xs">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{client.tags.length > 3 && (
|
||||
<span className="text-xs text-slate-400">+{client.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-slate-100 text-xs text-slate-400">
|
||||
Last contacted: {getRelativeTime(client.lastContacted)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Add Client" size="lg">
|
||||
<ClientForm onSubmit={handleCreate} loading={creating} />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user