feat: production hardening UI - tags page, onboarding wizard, pagination
- 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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user