feat: client pipeline view + notes tab + stage badges

- Pipeline/kanban view on Clients page (toggle grid/pipeline)
- Pipeline summary bar showing client distribution across stages
- Stage badge on client cards and detail page (click to cycle)
- Notes tab on ClientDetailPage with add/edit/pin/delete
- StageBadge component with color-coded labels
- Stage selector in ClientForm
- API client methods for notes CRUD
This commit is contained in:
2026-01-30 00:35:50 +00:00
parent 38761586e7
commit b43bdf3c71
7 changed files with 455 additions and 24 deletions

View File

@@ -1,9 +1,9 @@
import { useEffect, useState, useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useClientsStore } from '@/stores/clients';
import { Search, Plus, Users, X, Upload } from 'lucide-react';
import { Search, Plus, Users, X, Upload, LayoutGrid, List, Kanban } from 'lucide-react';
import { cn, getRelativeTime, getInitials } from '@/lib/utils';
import Badge from '@/components/Badge';
import Badge, { StageBadge } from '@/components/Badge';
import EmptyState from '@/components/EmptyState';
import { PageLoader } from '@/components/LoadingSpinner';
import Modal from '@/components/Modal';
@@ -16,6 +16,9 @@ export default function ClientsPage() {
const [showCreate, setShowCreate] = useState(false);
const [showImport, setShowImport] = useState(false);
const [creating, setCreating] = useState(false);
const [viewMode, setViewMode] = useState<'grid' | 'pipeline'>(() =>
(localStorage.getItem('clients-view') as 'grid' | 'pipeline') || 'grid'
);
useEffect(() => {
fetchClients();
@@ -46,6 +49,27 @@ export default function ClientsPage() {
return result;
}, [clients, searchQuery, selectedTag]);
// Pipeline columns
const pipelineStages = ['lead', 'prospect', 'onboarding', 'active', 'inactive'] as const;
const pipelineColumns = useMemo(() => {
const cols: Record<string, typeof filteredClients> = {};
pipelineStages.forEach(s => { cols[s] = []; });
filteredClients.forEach(c => {
const stage = c.stage || 'lead';
if (cols[stage]) cols[stage].push(c);
});
return cols;
}, [filteredClients]);
const stageCounts = useMemo(() => {
const counts: Record<string, number> = {};
clients.forEach(c => {
const s = c.stage || 'lead';
counts[s] = (counts[s] || 0) + 1;
});
return counts;
}, [clients]);
// All unique tags
const allTags = useMemo(() => {
const tags = new Set<string>();
@@ -75,6 +99,28 @@ export default function ClientsPage() {
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">{clients.length} contacts in your network</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center bg-slate-100 dark:bg-slate-800 rounded-lg p-0.5">
<button
onClick={() => { setViewMode('grid'); localStorage.setItem('clients-view', 'grid'); }}
className={cn(
'p-1.5 rounded-md transition-colors',
viewMode === 'grid' ? 'bg-white dark:bg-slate-700 shadow-sm text-slate-900 dark:text-slate-100' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'
)}
title="Grid view"
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => { setViewMode('pipeline'); localStorage.setItem('clients-view', 'pipeline'); }}
className={cn(
'p-1.5 rounded-md transition-colors',
viewMode === 'pipeline' ? 'bg-white dark:bg-slate-700 shadow-sm text-slate-900 dark:text-slate-100' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'
)}
title="Pipeline view"
>
<Kanban className="w-4 h-4" />
</button>
</div>
<button
onClick={() => setShowImport(true)}
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
@@ -131,7 +177,36 @@ export default function ClientsPage() {
)}
</div>
{/* Client Grid */}
{/* Pipeline Summary Bar */}
{viewMode === 'grid' && clients.length > 0 && (
<div className="flex items-center gap-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-3 overflow-x-auto">
{pipelineStages.map((stage) => {
const count = stageCounts[stage] || 0;
const total = clients.length;
const pct = total > 0 ? Math.max((count / total) * 100, count > 0 ? 4 : 0) : 0;
const colors: Record<string, string> = {
lead: 'bg-slate-300 dark:bg-slate-600',
prospect: 'bg-blue-400 dark:bg-blue-500',
onboarding: 'bg-amber-400 dark:bg-amber-500',
active: 'bg-emerald-400 dark:bg-emerald-500',
inactive: 'bg-red-400 dark:bg-red-500',
};
return (
<div key={stage} className="flex-1 min-w-[60px]" title={`${stage}: ${count}`}>
<div className="text-center mb-1">
<span className="text-xs font-medium text-slate-600 dark:text-slate-300 capitalize">{stage}</span>
<span className="text-xs text-slate-400 ml-1">({count})</span>
</div>
<div className="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div className={cn('h-full rounded-full transition-all', colors[stage])} style={{ width: `${pct}%` }} />
</div>
</div>
);
})}
</div>
)}
{/* Client Grid / Pipeline View */}
{filteredClients.length === 0 ? (
<EmptyState
icon={Users}
@@ -139,7 +214,69 @@ export default function ClientsPage() {
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}
/>
) : viewMode === 'pipeline' ? (
/* Pipeline / Kanban View */
<div className="flex gap-4 overflow-x-auto pb-4">
{pipelineStages.map((stage) => {
const stageClients = pipelineColumns[stage] || [];
const headerColors: Record<string, string> = {
lead: 'border-t-slate-400',
prospect: 'border-t-blue-500',
onboarding: 'border-t-amber-500',
active: 'border-t-emerald-500',
inactive: 'border-t-red-500',
};
return (
<div key={stage} className={cn(
'flex-1 min-w-[220px] max-w-[300px] bg-slate-50 dark:bg-slate-800/50 rounded-xl border-t-4',
headerColors[stage]
)}>
<div className="px-3 py-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 capitalize">{stage}</h3>
<span className="text-xs bg-white dark:bg-slate-700 text-slate-500 dark:text-slate-400 px-2 py-0.5 rounded-full font-medium">{stageClients.length}</span>
</div>
<div className="px-2 pb-2 space-y-2 max-h-[600px] overflow-y-auto">
{stageClients.map((client) => (
<Link
key={client.id}
to={`/clients/${client.id}`}
className="block bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-3 hover:shadow-md dark:hover:shadow-slate-900/50 transition-all"
>
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
{getInitials(client.firstName, client.lastName)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
{client.firstName} {client.lastName}
</p>
{client.company && (
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">{client.company}</p>
)}
</div>
</div>
{client.tags && client.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{client.tags.slice(0, 2).map(tag => (
<span key={tag} className="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400 rounded text-[10px]">{tag}</span>
))}
</div>
)}
<p className="text-[10px] text-slate-400 mt-2">
{getRelativeTime(client.lastContacted)}
</p>
</Link>
))}
{stageClients.length === 0 && (
<p className="text-xs text-slate-400 text-center py-4">No clients</p>
)}
</div>
</div>
);
})}
</div>
) : (
/* Grid View */
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredClients.map((client) => (
<Link
@@ -161,18 +298,17 @@ export default function ClientsPage() {
</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 dark:bg-slate-700 text-slate-600 dark:text-slate-300 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="flex flex-wrap gap-1.5 mt-3">
<StageBadge stage={client.stage} />
{client.tags && client.tags.slice(0, 2).map((tag) => (
<span key={tag} className="px-2 py-0.5 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded-full text-xs">
{tag}
</span>
))}
{client.tags && client.tags.length > 2 && (
<span className="text-xs text-slate-400">+{client.tags.length - 2}</span>
)}
</div>
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700 text-xs text-slate-400">
Last contacted: {getRelativeTime(client.lastContacted)}