feat: production hardening UI - tags page, onboarding wizard, pagination
Some checks failed
CI/CD / test (push) Failing after 21s
CI/CD / deploy (push) Has been skipped

- Tags management page: grid cards, rename/delete/merge modals, color-coded
- Onboarding wizard: 4-step full-screen flow for new users (welcome, client, style, tour)
- Client list pagination: page controls, page size selector, URL query params
- Pipeline view unaffected (shows all clients)
- Tags added to sidebar navigation
- All components support dark mode
This commit is contained in:
2026-01-30 01:37:40 +00:00
parent 1340893144
commit 7a956aebec
6 changed files with 867 additions and 7 deletions

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useEffect, useState, useMemo, useCallback } from 'react';
import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { useClientsStore } from '@/stores/clients';
import { Search, Plus, Users, X, Upload, LayoutGrid, List, Kanban } from 'lucide-react';
import { Search, Plus, Users, X, Upload, LayoutGrid, List, Kanban, ChevronLeft, ChevronRight } from 'lucide-react';
import { cn, getRelativeTime, getInitials } from '@/lib/utils';
import Badge, { StageBadge } from '@/components/Badge';
import EmptyState from '@/components/EmptyState';
@@ -10,8 +10,11 @@ import Modal from '@/components/Modal';
import ClientForm from '@/components/ClientForm';
import CSVImportModal from '@/components/CSVImportModal';
const PAGE_SIZES = [25, 50, 100];
export default function ClientsPage() {
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const { clients, isLoading, searchQuery, selectedTag, setSearchQuery, setSelectedTag, fetchClients, createClient } = useClientsStore();
const [showCreate, setShowCreate] = useState(false);
const [showImport, setShowImport] = useState(false);
@@ -20,6 +23,31 @@ export default function ClientsPage() {
(localStorage.getItem('clients-view') as 'grid' | 'pipeline') || 'grid'
);
// Pagination state from URL
const currentPage = parseInt(searchParams.get('page') || '1', 10) || 1;
const pageSize = parseInt(searchParams.get('pageSize') || '50', 10) || 50;
const setPage = useCallback((page: number) => {
const params = new URLSearchParams(searchParams);
params.set('page', String(page));
setSearchParams(params, { replace: true });
}, [searchParams, setSearchParams]);
const setPageSize = useCallback((size: number) => {
const params = new URLSearchParams(searchParams);
params.set('pageSize', String(size));
params.set('page', '1'); // reset to first page
setSearchParams(params, { replace: true });
}, [searchParams, setSearchParams]);
// Initialize selected tag from URL
useEffect(() => {
const urlTag = searchParams.get('tag');
if (urlTag && urlTag !== selectedTag) {
setSelectedTag(urlTag);
}
}, []);
useEffect(() => {
fetchClients();
}, [fetchClients]);
@@ -49,6 +77,22 @@ export default function ClientsPage() {
return result;
}, [clients, searchQuery, selectedTag]);
// Pagination for grid view
const totalClients = filteredClients.length;
const totalPages = Math.ceil(totalClients / pageSize);
const paginatedClients = useMemo(() => {
if (viewMode === 'pipeline') return filteredClients; // pipeline shows all
const start = (currentPage - 1) * pageSize;
return filteredClients.slice(start, start + pageSize);
}, [filteredClients, currentPage, pageSize, viewMode]);
// Reset page when filters change
useEffect(() => {
if (currentPage > 1) {
setPage(1);
}
}, [searchQuery, selectedTag]);
// Pipeline columns
const pipelineStages = ['lead', 'prospect', 'onboarding', 'active', 'inactive'] as const;
const pipelineColumns = useMemo(() => {
@@ -95,7 +139,9 @@ export default function ClientsPage() {
<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 dark:text-slate-100">Clients</h1>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Clients{clients.length > 0 && <span className="text-slate-400 dark:text-slate-500 ml-2 text-lg font-normal">({clients.length})</span>}
</h1>
<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">
@@ -278,7 +324,7 @@ export default function ClientsPage() {
) : (
/* Grid View */
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredClients.map((client) => (
{paginatedClients.map((client) => (
<Link
key={client.id}
to={`/clients/${client.id}`}
@@ -318,6 +364,46 @@ export default function ClientsPage() {
</div>
)}
{/* Pagination Controls (grid view only) */}
{viewMode === 'grid' && totalPages > 1 && (
<div className="flex items-center justify-between bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl px-4 py-3">
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<span>Show</span>
<select
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
className="px-2 py-1 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{PAGE_SIZES.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span>per page</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500 dark:text-slate-400">
Page {currentPage} of {totalPages}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(currentPage - 1)}
disabled={currentPage <= 1}
className="p-1.5 rounded-lg border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => setPage(currentPage + 1)}
disabled={currentPage >= totalPages}
className="p-1.5 rounded-lg border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
{/* Create Modal */}
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Add Client" size="lg">
<ClientForm onSubmit={handleCreate} loading={creating} />

329
src/pages/TagsPage.tsx Normal file
View File

@@ -0,0 +1,329 @@
import { useEffect, useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import { Tag, Pencil, Trash2, Merge, Plus, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import EmptyState from '@/components/EmptyState';
import { PageLoader } from '@/components/LoadingSpinner';
import LoadingSpinner from '@/components/LoadingSpinner';
import Modal from '@/components/Modal';
interface TagInfo {
name: string;
count: number;
}
// Hash-based color palette
const TAG_COLORS = [
{ bg: 'bg-blue-100 dark:bg-blue-900/40', text: 'text-blue-700 dark:text-blue-300', border: 'border-blue-200 dark:border-blue-800' },
{ bg: 'bg-emerald-100 dark:bg-emerald-900/40', text: 'text-emerald-700 dark:text-emerald-300', border: 'border-emerald-200 dark:border-emerald-800' },
{ bg: 'bg-purple-100 dark:bg-purple-900/40', text: 'text-purple-700 dark:text-purple-300', border: 'border-purple-200 dark:border-purple-800' },
{ bg: 'bg-amber-100 dark:bg-amber-900/40', text: 'text-amber-700 dark:text-amber-300', border: 'border-amber-200 dark:border-amber-800' },
{ bg: 'bg-pink-100 dark:bg-pink-900/40', text: 'text-pink-700 dark:text-pink-300', border: 'border-pink-200 dark:border-pink-800' },
{ bg: 'bg-cyan-100 dark:bg-cyan-900/40', text: 'text-cyan-700 dark:text-cyan-300', border: 'border-cyan-200 dark:border-cyan-800' },
{ bg: 'bg-orange-100 dark:bg-orange-900/40', text: 'text-orange-700 dark:text-orange-300', border: 'border-orange-200 dark:border-orange-800' },
{ bg: 'bg-indigo-100 dark:bg-indigo-900/40', text: 'text-indigo-700 dark:text-indigo-300', border: 'border-indigo-200 dark:border-indigo-800' },
{ bg: 'bg-rose-100 dark:bg-rose-900/40', text: 'text-rose-700 dark:text-rose-300', border: 'border-rose-200 dark:border-rose-800' },
{ bg: 'bg-teal-100 dark:bg-teal-900/40', text: 'text-teal-700 dark:text-teal-300', border: 'border-teal-200 dark:border-teal-800' },
];
function hashColor(str: string) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length];
}
export default function TagsPage() {
const navigate = useNavigate();
const [tags, setTags] = useState<TagInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Rename modal
const [renameTag, setRenameTag] = useState<TagInfo | null>(null);
const [newName, setNewName] = useState('');
const [renaming, setRenaming] = useState(false);
// Delete confirm
const [deleteTag, setDeleteTag] = useState<TagInfo | null>(null);
const [deleting, setDeleting] = useState(false);
// Merge modal
const [showMerge, setShowMerge] = useState(false);
const [mergeSelected, setMergeSelected] = useState<Set<string>>(new Set());
const [mergeTarget, setMergeTarget] = useState('');
const [merging, setMerging] = useState(false);
const fetchTags = async () => {
setIsLoading(true);
try {
const data = await api.getTags();
setTags(data);
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
useEffect(() => { fetchTags(); }, []);
const handleRename = async () => {
if (!renameTag || !newName.trim()) return;
setRenaming(true);
try {
await api.renameTag(renameTag.name, newName.trim());
setRenameTag(null);
setNewName('');
await fetchTags();
} catch (err: any) {
setError(err.message);
} finally {
setRenaming(false);
}
};
const handleDelete = async () => {
if (!deleteTag) return;
setDeleting(true);
try {
await api.deleteTag(deleteTag.name);
setDeleteTag(null);
await fetchTags();
} catch (err: any) {
setError(err.message);
} finally {
setDeleting(false);
}
};
const handleMerge = async () => {
if (mergeSelected.size < 2 || !mergeTarget.trim()) return;
setMerging(true);
try {
const sourceTags = Array.from(mergeSelected).filter(t => t !== mergeTarget.trim());
await api.mergeTags(sourceTags, mergeTarget.trim());
setShowMerge(false);
setMergeSelected(new Set());
setMergeTarget('');
await fetchTags();
} catch (err: any) {
setError(err.message);
} finally {
setMerging(false);
}
};
if (isLoading) 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 dark:text-slate-100">Tags</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
{tags.length} tag{tags.length !== 1 ? 's' : ''} across your clients
</p>
</div>
{tags.length >= 2 && (
<button
onClick={() => setShowMerge(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"
>
<Merge className="w-4 h-4" />
Merge Tags
</button>
)}
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg text-sm">
{error}
<button onClick={() => setError(null)} className="ml-2 underline">dismiss</button>
</div>
)}
{tags.length === 0 ? (
<EmptyState
icon={Tag}
title="No tags yet"
description="Add tags to your clients to organize them. Tags will appear here."
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tags.map((tag) => {
const color = hashColor(tag.name);
return (
<div
key={tag.name}
className={cn(
'rounded-xl border p-4 transition-all hover:shadow-md dark:hover:shadow-slate-900/50 cursor-pointer group',
color.bg, color.border
)}
>
<div
className="flex items-start justify-between"
onClick={() => navigate(`/clients?tag=${encodeURIComponent(tag.name)}`)}
>
<div className="flex-1 min-w-0">
<h3 className={cn('font-semibold truncate', color.text)}>{tag.name}</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 flex items-center gap-1">
<Users className="w-3.5 h-3.5" />
{tag.count} client{tag.count !== 1 ? 's' : ''}
</p>
</div>
</div>
<div className="flex items-center gap-1 mt-3 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); setRenameTag(tag); setNewName(tag.name); }}
className="p-1.5 rounded-lg text-slate-400 hover:bg-white/60 dark:hover:bg-slate-700/60 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
title="Rename"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); setDeleteTag(tag); }}
className="p-1.5 rounded-lg text-slate-400 hover:bg-red-50 dark:hover:bg-red-900/30 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title="Delete"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
)}
{/* Rename Modal */}
<Modal isOpen={!!renameTag} onClose={() => setRenameTag(null)} title="Rename Tag">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Renaming: <span className="font-semibold">{renameTag?.name}</span>
</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="New tag name"
className="w-full px-3 py-2 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
/>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
This will rename the tag across all {renameTag?.count} client{renameTag?.count !== 1 ? 's' : ''}.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setRenameTag(null)}
className="px-4 py-2 text-sm text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-700 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600"
>
Cancel
</button>
<button
onClick={handleRename}
disabled={renaming || !newName.trim() || newName.trim() === renameTag?.name}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
>
{renaming && <LoadingSpinner />}
Rename
</button>
</div>
</div>
</Modal>
{/* Delete Confirm Modal */}
<Modal isOpen={!!deleteTag} onClose={() => setDeleteTag(null)} title="Delete Tag">
<div className="space-y-4">
<p className="text-sm text-slate-700 dark:text-slate-300">
Are you sure you want to delete the tag <span className="font-semibold">"{deleteTag?.name}"</span>?
It will be removed from {deleteTag?.count} client{deleteTag?.count !== 1 ? 's' : ''}.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setDeleteTag(null)}
className="px-4 py-2 text-sm text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-700 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="px-4 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 flex items-center gap-2"
>
{deleting && <LoadingSpinner />}
Delete
</button>
</div>
</div>
</Modal>
{/* Merge Modal */}
<Modal isOpen={showMerge} onClose={() => { setShowMerge(false); setMergeSelected(new Set()); setMergeTarget(''); }} title="Merge Tags" size="lg">
<div className="space-y-4">
<p className="text-sm text-slate-700 dark:text-slate-300">
Select tags to merge, then choose or type the target tag name.
</p>
<div className="flex flex-wrap gap-2 max-h-48 overflow-y-auto">
{tags.map((tag) => {
const selected = mergeSelected.has(tag.name);
const color = hashColor(tag.name);
return (
<button
key={tag.name}
onClick={() => {
const next = new Set(mergeSelected);
if (selected) next.delete(tag.name); else next.add(tag.name);
setMergeSelected(next);
}}
className={cn(
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-all',
selected
? 'bg-blue-600 text-white border-blue-600'
: cn(color.bg, color.text, color.border, 'hover:opacity-80')
)}
>
{tag.name} ({tag.count})
</button>
);
})}
</div>
{mergeSelected.size >= 2 && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Merge into tag name:
</label>
<input
type="text"
value={mergeTarget}
onChange={(e) => setMergeTarget(e.target.value)}
placeholder="Target tag name"
className="w-full px-3 py-2 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
)}
<div className="flex justify-end gap-2">
<button
onClick={() => { setShowMerge(false); setMergeSelected(new Set()); setMergeTarget(''); }}
className="px-4 py-2 text-sm text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-700 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600"
>
Cancel
</button>
<button
onClick={handleMerge}
disabled={merging || mergeSelected.size < 2 || !mergeTarget.trim()}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
>
{merging && <LoadingSpinner />}
Merge {mergeSelected.size} Tags
</button>
</div>
</div>
</Modal>
</div>
);
}