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:
@@ -53,3 +53,24 @@ export function EmailStatusBadge({ status }: { status: string }) {
|
|||||||
};
|
};
|
||||||
return <Badge color={colors[status] || 'gray'}>{status}</Badge>;
|
return <Badge color={colors[status] || 'gray'}>{status}</Badge>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stageLabels: Record<string, string> = {
|
||||||
|
lead: 'Lead',
|
||||||
|
prospect: 'Prospect',
|
||||||
|
onboarding: 'Onboarding',
|
||||||
|
active: 'Active',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
};
|
||||||
|
|
||||||
|
const stageColors: Record<string, keyof typeof colorMap> = {
|
||||||
|
lead: 'gray',
|
||||||
|
prospect: 'blue',
|
||||||
|
onboarding: 'yellow',
|
||||||
|
active: 'green',
|
||||||
|
inactive: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StageBadge({ stage, onClick }: { stage?: string; onClick?: () => void }) {
|
||||||
|
const s = stage || 'lead';
|
||||||
|
return <Badge color={stageColors[s] || 'gray'} onClick={onClick}>{stageLabels[s] || s}</Badge>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default function ClientForm({ initialData, onSubmit, loading }: ClientFor
|
|||||||
family: initialData?.family || { spouse: '', children: [] },
|
family: initialData?.family || { spouse: '', children: [] },
|
||||||
notes: initialData?.notes || '',
|
notes: initialData?.notes || '',
|
||||||
tags: initialData?.tags || [],
|
tags: initialData?.tags || [],
|
||||||
|
stage: initialData?.stage || 'lead',
|
||||||
});
|
});
|
||||||
|
|
||||||
const [tagInput, setTagInput] = useState('');
|
const [tagInput, setTagInput] = useState('');
|
||||||
@@ -109,6 +110,18 @@ export default function ClientForm({ initialData, onSubmit, loading }: ClientFor
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stage */}
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Pipeline Stage</label>
|
||||||
|
<select value={form.stage || 'lead'} onChange={(e) => update('stage', e.target.value)} className={inputClass}>
|
||||||
|
<option value="lead">Lead</option>
|
||||||
|
<option value="prospect">Prospect</option>
|
||||||
|
<option value="onboarding">Onboarding</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Address */}
|
{/* Address */}
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>Street</label>
|
<label className={labelClass}>Street</label>
|
||||||
|
|||||||
214
src/components/ClientNotes.tsx
Normal file
214
src/components/ClientNotes.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { ClientNote } from '@/types';
|
||||||
|
import { Pin, Trash2, Edit3, Check, X, Plus, StickyNote } from 'lucide-react';
|
||||||
|
import { cn, getRelativeTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ClientNotesProps {
|
||||||
|
clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientNotes({ clientId }: ClientNotesProps) {
|
||||||
|
const [notes, setNotes] = useState<ClientNote[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [newNote, setNewNote] = useState('');
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editContent, setEditContent] = useState('');
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const fetchNotes = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getClientNotes(clientId);
|
||||||
|
setNotes(data);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotes();
|
||||||
|
}, [clientId]);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!newNote.trim()) return;
|
||||||
|
setAdding(true);
|
||||||
|
try {
|
||||||
|
const note = await api.createClientNote(clientId, newNote.trim());
|
||||||
|
setNotes(prev => [note, ...prev]);
|
||||||
|
setNewNote('');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (noteId: string) => {
|
||||||
|
if (!confirm('Delete this note?')) return;
|
||||||
|
try {
|
||||||
|
await api.deleteClientNote(clientId, noteId);
|
||||||
|
setNotes(prev => prev.filter(n => n.id !== noteId));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTogglePin = async (note: ClientNote) => {
|
||||||
|
try {
|
||||||
|
const updated = await api.updateClientNote(clientId, note.id, { pinned: !note.pinned });
|
||||||
|
setNotes(prev => prev.map(n => n.id === note.id ? updated : n)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.pinned && !b.pinned) return -1;
|
||||||
|
if (!a.pinned && b.pinned) return 1;
|
||||||
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartEdit = (note: ClientNote) => {
|
||||||
|
setEditingId(note.id);
|
||||||
|
setEditContent(note.content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async (noteId: string) => {
|
||||||
|
if (!editContent.trim()) return;
|
||||||
|
try {
|
||||||
|
const updated = await api.updateClientNote(clientId, noteId, { content: editContent.trim() });
|
||||||
|
setNotes(prev => prev.map(n => n.id === noteId ? updated : n));
|
||||||
|
setEditingId(null);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-8 text-center">
|
||||||
|
<p className="text-sm text-slate-400">Loading notes...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* New note input */}
|
||||||
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={newNote}
|
||||||
|
onChange={(e) => setNewNote(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAdd();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Add a note... (⌘+Enter to save)"
|
||||||
|
className="w-full bg-transparent text-sm text-slate-900 dark:text-slate-100 placeholder-slate-400 resize-none outline-none min-h-[80px]"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end mt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={adding || !newNote.trim()}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors',
|
||||||
|
newNote.trim()
|
||||||
|
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-700 text-slate-400 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add Note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes list */}
|
||||||
|
{notes.length === 0 ? (
|
||||||
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-8 text-center">
|
||||||
|
<StickyNote className="w-8 h-8 text-slate-300 dark:text-slate-600 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-slate-400">No notes yet. Add your first note above.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{notes.map(note => (
|
||||||
|
<div
|
||||||
|
key={note.id}
|
||||||
|
className={cn(
|
||||||
|
'bg-white dark:bg-slate-800 border rounded-xl p-4 group',
|
||||||
|
note.pinned
|
||||||
|
? 'border-amber-200 dark:border-amber-800 bg-amber-50/50 dark:bg-amber-900/10'
|
||||||
|
: 'border-slate-200 dark:border-slate-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{editingId === note.id ? (
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
value={editContent}
|
||||||
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
|
className="w-full bg-transparent text-sm text-slate-900 dark:text-slate-100 resize-none outline-none min-h-[60px] border border-slate-200 dark:border-slate-600 rounded-lg p-2"
|
||||||
|
rows={3}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 mt-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" /> Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveEdit(note.id)}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" /> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-slate-800 dark:text-slate-200 whitespace-pre-wrap">{note.content}</p>
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<span className="text-xs text-slate-400">{getRelativeTime(note.createdAt)}</span>
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTogglePin(note)}
|
||||||
|
className={cn(
|
||||||
|
'p-1 rounded transition-colors',
|
||||||
|
note.pinned ? 'text-amber-500 hover:text-amber-600' : 'text-slate-400 hover:text-amber-500'
|
||||||
|
)}
|
||||||
|
title={note.pinned ? 'Unpin' : 'Pin'}
|
||||||
|
>
|
||||||
|
<Pin className={cn('w-3.5 h-3.5', note.pinned && 'fill-amber-500')} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartEdit(note)}
|
||||||
|
className="p-1 rounded text-slate-400 hover:text-blue-500 transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(note.id)}
|
||||||
|
className="p-1 rounded text-slate-400 hover:text-red-500 transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Profile, Client, ClientCreate, Event, EventCreate, Email, EmailGenerate, User, Invite, ActivityItem, InsightsData, ImportPreview, ImportResult, NetworkMatch, NetworkStats } from '@/types';
|
import type { Profile, Client, ClientCreate, ClientNote, Event, EventCreate, Email, EmailGenerate, User, Invite, ActivityItem, InsightsData, ImportPreview, ImportResult, NetworkMatch, NetworkStats } from '@/types';
|
||||||
|
|
||||||
const API_BASE = import.meta.env.PROD
|
const API_BASE = import.meta.env.PROD
|
||||||
? 'https://api.thenetwork.donovankelly.xyz/api'
|
? 'https://api.thenetwork.donovankelly.xyz/api'
|
||||||
@@ -404,6 +404,29 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
// Client Notes
|
||||||
|
async getClientNotes(clientId: string): Promise<ClientNote[]> {
|
||||||
|
return this.fetch(`/clients/${clientId}/notes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createClientNote(clientId: string, content: string): Promise<ClientNote> {
|
||||||
|
return this.fetch(`/clients/${clientId}/notes`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateClientNote(clientId: string, noteId: string, data: { content?: string; pinned?: boolean }): Promise<ClientNote> {
|
||||||
|
return this.fetch(`/clients/${clientId}/notes/${noteId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteClientNote(clientId: string, noteId: string): Promise<void> {
|
||||||
|
await this.fetch(`/clients/${clientId}/notes/${noteId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
// Reports & Analytics
|
// Reports & Analytics
|
||||||
async getReportsOverview(): Promise<any> {
|
async getReportsOverview(): Promise<any> {
|
||||||
return this.fetch('/reports/overview');
|
return this.fetch('/reports/overview');
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { usePinnedClients } from '@/hooks/usePinnedClients';
|
import { usePinnedClients } from '@/hooks/usePinnedClients';
|
||||||
import { cn, formatDate, getRelativeTime, getInitials } from '@/lib/utils';
|
import { cn, formatDate, getRelativeTime, getInitials } from '@/lib/utils';
|
||||||
import Badge, { EventTypeBadge, EmailStatusBadge } from '@/components/Badge';
|
import Badge, { EventTypeBadge, EmailStatusBadge, StageBadge } from '@/components/Badge';
|
||||||
import { PageLoader } from '@/components/LoadingSpinner';
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
import ClientForm from '@/components/ClientForm';
|
import ClientForm from '@/components/ClientForm';
|
||||||
import EmailComposeModal from '@/components/EmailComposeModal';
|
import EmailComposeModal from '@/components/EmailComposeModal';
|
||||||
|
import ClientNotes from '@/components/ClientNotes';
|
||||||
|
|
||||||
export default function ClientDetailPage() {
|
export default function ClientDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -23,7 +24,7 @@ export default function ClientDetailPage() {
|
|||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [emails, setEmails] = useState<Email[]>([]);
|
const [emails, setEmails] = useState<Email[]>([]);
|
||||||
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState<'info' | 'activity' | 'events' | 'emails'>('info');
|
const [activeTab, setActiveTab] = useState<'info' | 'notes' | 'activity' | 'events' | 'emails'>('info');
|
||||||
const [showEdit, setShowEdit] = useState(false);
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
const [showCompose, setShowCompose] = useState(false);
|
const [showCompose, setShowCompose] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
@@ -62,8 +63,9 @@ export default function ClientDetailPage() {
|
|||||||
setShowEdit(false);
|
setShowEdit(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabs: { key: 'info' | 'activity' | 'events' | 'emails'; label: string; count?: number; icon: typeof Users }[] = [
|
const tabs: { key: typeof activeTab; label: string; count?: number; icon: typeof Users }[] = [
|
||||||
{ key: 'info', label: 'Info', icon: Users },
|
{ key: 'info', label: 'Info', icon: Users },
|
||||||
|
{ key: 'notes', label: 'Notes', icon: FileText },
|
||||||
{ key: 'activity', label: 'Timeline', count: activities.length, icon: Activity },
|
{ key: 'activity', label: 'Timeline', count: activities.length, icon: Activity },
|
||||||
{ key: 'events', label: 'Events', count: events.length, icon: Calendar },
|
{ key: 'events', label: 'Events', count: events.length, icon: Calendar },
|
||||||
{ key: 'emails', label: 'Emails', count: emails.length, icon: Mail },
|
{ key: 'emails', label: 'Emails', count: emails.length, icon: Mail },
|
||||||
@@ -90,14 +92,20 @@ export default function ClientDetailPage() {
|
|||||||
{client.role ? `${client.role} at ` : ''}{client.company}
|
{client.role ? `${client.role} at ` : ''}{client.company}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{client.tags && client.tags.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
{client.tags.map((tag) => <Badge key={tag} color="blue">{tag}</Badge>)}
|
<StageBadge stage={client.stage} onClick={async () => {
|
||||||
</div>
|
const stages = ['lead', 'prospect', 'onboarding', 'active', 'inactive'];
|
||||||
|
const currentIdx = stages.indexOf(client.stage || 'lead');
|
||||||
|
const nextStage = stages[(currentIdx + 1) % stages.length];
|
||||||
|
await updateClient(client.id, { stage: nextStage } as any);
|
||||||
|
}} />
|
||||||
|
{client.tags && client.tags.length > 0 && (
|
||||||
|
client.tags.map((tag) => <Badge key={tag} color="blue">{tag}</Badge>)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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">
|
<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" />
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
@@ -240,6 +248,10 @@ export default function ClientDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'notes' && (
|
||||||
|
<ClientNotes clientId={client.id} />
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'activity' && (
|
{activeTab === 'activity' && (
|
||||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">
|
||||||
{activities.length === 0 ? (
|
{activities.length === 0 ? (
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { useClientsStore } from '@/stores/clients';
|
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 { cn, getRelativeTime, getInitials } from '@/lib/utils';
|
||||||
import Badge from '@/components/Badge';
|
import Badge, { StageBadge } from '@/components/Badge';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
import { PageLoader } from '@/components/LoadingSpinner';
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
@@ -16,6 +16,9 @@ export default function ClientsPage() {
|
|||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [showImport, setShowImport] = useState(false);
|
const [showImport, setShowImport] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'pipeline'>(() =>
|
||||||
|
(localStorage.getItem('clients-view') as 'grid' | 'pipeline') || 'grid'
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchClients();
|
fetchClients();
|
||||||
@@ -46,6 +49,27 @@ export default function ClientsPage() {
|
|||||||
return result;
|
return result;
|
||||||
}, [clients, searchQuery, selectedTag]);
|
}, [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
|
// All unique tags
|
||||||
const allTags = useMemo(() => {
|
const allTags = useMemo(() => {
|
||||||
const tags = new Set<string>();
|
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>
|
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">{clients.length} contacts in your network</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<button
|
||||||
onClick={() => setShowImport(true)}
|
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"
|
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>
|
</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 ? (
|
{filteredClients.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Users}
|
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'}
|
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}
|
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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{filteredClients.map((client) => (
|
{filteredClients.map((client) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -161,18 +298,17 @@ export default function ClientsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{client.tags && client.tags.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||||
{client.tags.slice(0, 3).map((tag) => (
|
<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">
|
<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}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{client.tags.length > 3 && (
|
{client.tags && client.tags.length > 2 && (
|
||||||
<span className="text-xs text-slate-400">+{client.tags.length - 3}</span>
|
<span className="text-xs text-slate-400">+{client.tags.length - 2}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700 text-xs text-slate-400">
|
<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)}
|
Last contacted: {getRelativeTime(client.lastContacted)}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface Client {
|
|||||||
};
|
};
|
||||||
notes?: string;
|
notes?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
stage?: 'lead' | 'prospect' | 'onboarding' | 'active' | 'inactive';
|
||||||
lastContacted?: string;
|
lastContacted?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -66,6 +67,17 @@ export interface ClientCreate {
|
|||||||
};
|
};
|
||||||
notes?: string;
|
notes?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
stage?: 'lead' | 'prospect' | 'onboarding' | 'active' | 'inactive';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientNote {
|
||||||
|
id: string;
|
||||||
|
clientId: string;
|
||||||
|
userId: string;
|
||||||
|
content: string;
|
||||||
|
pinned: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Event {
|
export interface Event {
|
||||||
|
|||||||
Reference in New Issue
Block a user