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:
@@ -23,6 +23,7 @@ const InvitePage = lazy(() => import('@/pages/InvitePage'));
|
||||
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'));
|
||||
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'));
|
||||
const AuditLogPage = lazy(() => import('@/pages/AuditLogPage'));
|
||||
const TagsPage = lazy(() => import('@/pages/TagsPage'));
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
@@ -76,6 +77,7 @@ export default function App() {
|
||||
<Route path="network" element={<PageErrorBoundary><NetworkPage /></PageErrorBoundary>} />
|
||||
<Route path="reports" element={<PageErrorBoundary><ReportsPage /></PageErrorBoundary>} />
|
||||
<Route path="templates" element={<PageErrorBoundary><TemplatesPage /></PageErrorBoundary>} />
|
||||
<Route path="tags" element={<PageErrorBoundary><TagsPage /></PageErrorBoundary>} />
|
||||
<Route path="segments" element={<PageErrorBoundary><SegmentsPage /></PageErrorBoundary>} />
|
||||
<Route path="settings" element={<PageErrorBoundary><SettingsPage /></PageErrorBoundary>} />
|
||||
<Route path="admin" element={<PageErrorBoundary><AdminPage /></PageErrorBoundary>} />
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useLocation, Outlet } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
LayoutDashboard, Users, Calendar, Mail, Settings, Shield,
|
||||
LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, Command,
|
||||
FileText, Bookmark, ScrollText,
|
||||
FileText, Bookmark, ScrollText, Tag,
|
||||
} from 'lucide-react';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import CommandPalette from './CommandPalette';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import OnboardingWizard from './OnboardingWizard';
|
||||
|
||||
const baseNavItems = [
|
||||
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
@@ -18,6 +20,7 @@ const baseNavItems = [
|
||||
{ path: '/emails', label: 'Emails', icon: Mail },
|
||||
{ path: '/network', label: 'Network', icon: Network },
|
||||
{ path: '/templates', label: 'Templates', icon: FileText },
|
||||
{ path: '/tags', label: 'Tags', icon: Tag },
|
||||
{ path: '/segments', label: 'Segments', icon: Bookmark },
|
||||
{ path: '/reports', label: 'Reports', icon: BarChart3 },
|
||||
{ path: '/settings', label: 'Settings', icon: Settings },
|
||||
@@ -31,16 +34,36 @@ const adminNavItems = [
|
||||
export default function Layout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuthStore();
|
||||
const navItems = user?.role === 'admin' ? [...baseNavItems, ...adminNavItems] : baseNavItems;
|
||||
|
||||
// Check onboarding status on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.getOnboardingStatus()
|
||||
.then((status) => {
|
||||
if (!cancelled && !status.onboardingComplete) {
|
||||
setShowOnboarding(true);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* ignore - user might not have profile yet */ });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-slate-50 dark:bg-slate-900">
|
||||
{showOnboarding && (
|
||||
<OnboardingWizard
|
||||
userName={user?.name || ''}
|
||||
onComplete={() => setShowOnboarding(false)}
|
||||
/>
|
||||
)}
|
||||
<CommandPalette />
|
||||
{/* Mobile overlay */}
|
||||
{mobileOpen && (
|
||||
|
||||
371
src/components/OnboardingWizard.tsx
Normal file
371
src/components/OnboardingWizard.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Sparkles, User, UserPlus, MessageSquare, Compass,
|
||||
ChevronRight, ChevronLeft, X, Check,
|
||||
} from 'lucide-react';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
userName: string;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{ id: 'welcome', label: 'Welcome', icon: Sparkles },
|
||||
{ id: 'client', label: 'First Client', icon: UserPlus },
|
||||
{ id: 'style', label: 'Communication', icon: MessageSquare },
|
||||
{ id: 'tour', label: 'Quick Tour', icon: Compass },
|
||||
] as const;
|
||||
|
||||
const TONES = [
|
||||
{ value: 'formal' as const, label: 'Formal', desc: 'Professional and polished' },
|
||||
{ value: 'friendly' as const, label: 'Friendly', desc: 'Warm and approachable' },
|
||||
{ value: 'casual' as const, label: 'Casual', desc: 'Relaxed and conversational' },
|
||||
];
|
||||
|
||||
const TOUR_FEATURES = [
|
||||
{ icon: '👥', title: 'Clients', desc: 'Manage your contacts with detailed profiles, tags, and pipeline stages.' },
|
||||
{ icon: '📧', title: 'AI Emails', desc: 'Generate personalized emails with AI that matches your writing style.' },
|
||||
{ icon: '📅', title: 'Events', desc: 'Never miss a birthday, anniversary, or follow-up reminder.' },
|
||||
{ icon: '🔗', title: 'Network', desc: 'Discover connections between your clients for intro opportunities.' },
|
||||
{ icon: '📊', title: 'Reports', desc: 'Track engagement metrics and grow your network intentionally.' },
|
||||
];
|
||||
|
||||
export default function OnboardingWizard({ userName, onComplete }: OnboardingWizardProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Step 1: Profile
|
||||
const [name, setName] = useState(userName || '');
|
||||
const [title, setTitle] = useState('');
|
||||
const [company, setCompany] = useState('');
|
||||
|
||||
// Step 2: First client
|
||||
const [clientFirstName, setClientFirstName] = useState('');
|
||||
const [clientLastName, setClientLastName] = useState('');
|
||||
const [clientEmail, setClientEmail] = useState('');
|
||||
const [clientCompany, setClientCompany] = useState('');
|
||||
const [clientAdded, setClientAdded] = useState(false);
|
||||
|
||||
// Step 3: Communication style
|
||||
const [tone, setTone] = useState<'formal' | 'friendly' | 'casual'>('friendly');
|
||||
const [greeting, setGreeting] = useState('');
|
||||
const [signoff, setSignoff] = useState('');
|
||||
|
||||
const handleSkip = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.completeOnboarding();
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('Failed to complete onboarding:', err);
|
||||
onComplete();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (step === 0) {
|
||||
// Save profile
|
||||
if (name || title || company) {
|
||||
try {
|
||||
await api.updateProfile({ name: name || undefined, title: title || undefined, company: company || undefined });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
setStep(1);
|
||||
} else if (step === 1) {
|
||||
// Add client if filled
|
||||
if (clientFirstName && clientLastName && !clientAdded) {
|
||||
try {
|
||||
await api.createClient({
|
||||
firstName: clientFirstName,
|
||||
lastName: clientLastName,
|
||||
email: clientEmail || undefined,
|
||||
company: clientCompany || undefined,
|
||||
});
|
||||
setClientAdded(true);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
setStep(2);
|
||||
} else if (step === 2) {
|
||||
// Save communication style
|
||||
try {
|
||||
await api.updateCommunicationStyle({
|
||||
tone,
|
||||
greeting: greeting || undefined,
|
||||
signoff: signoff || undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
setStep(3);
|
||||
} else if (step === 3) {
|
||||
// Complete
|
||||
await handleSkip();
|
||||
}
|
||||
};
|
||||
|
||||
const currentStep = STEPS[step];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-2xl mx-4 bg-white dark:bg-slate-800 rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
|
||||
{/* Progress bar */}
|
||||
<div className="h-1.5 bg-slate-100 dark:bg-slate-700">
|
||||
<div
|
||||
className="h-full bg-blue-600 transition-all duration-500 ease-out"
|
||||
style={{ width: `${((step + 1) / STEPS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="flex items-center justify-center gap-2 px-6 pt-6">
|
||||
{STEPS.map((s, i) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors',
|
||||
i === step
|
||||
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'
|
||||
: i < step
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-slate-400 dark:text-slate-500'
|
||||
)}
|
||||
>
|
||||
{i < step ? <Check className="w-3.5 h-3.5" /> : <Icon className="w-3.5 h-3.5" />}
|
||||
<span className="hidden sm:inline">{s.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 sm:px-10 py-8 min-h-[360px]">
|
||||
{step === 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-blue-100 dark:bg-blue-900/40 rounded-2xl mb-4">
|
||||
<Sparkles className="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Welcome to NetworkCRM!</h2>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-2">Let's get you set up. This only takes a minute.</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Your Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Full name"
|
||||
className="w-full px-3 py-2.5 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Financial Advisor"
|
||||
className="w-full px-3 py-2.5 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Company</label>
|
||||
<input
|
||||
type="text"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
placeholder="e.g., ABC Financial"
|
||||
className="w-full px-3 py-2.5 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-emerald-100 dark:bg-emerald-900/40 rounded-2xl mb-4">
|
||||
<UserPlus className="w-8 h-8 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Add Your First Client</h2>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-2">Get started by adding someone from your network.</p>
|
||||
</div>
|
||||
{clientAdded ? (
|
||||
<div className="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-xl p-4 text-center">
|
||||
<Check className="w-8 h-8 text-emerald-600 dark:text-emerald-400 mx-auto mb-2" />
|
||||
<p className="font-medium text-emerald-700 dark:text-emerald-300">
|
||||
{clientFirstName} {clientLastName} added!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">First Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={clientFirstName}
|
||||
onChange={(e) => setClientFirstName(e.target.value)}
|
||||
className="w-full px-3 py-2.5 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Last Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={clientLastName}
|
||||
onChange={(e) => setClientLastName(e.target.value)}
|
||||
className="w-full px-3 py-2.5 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={clientEmail}
|
||||
onChange={(e) => setClientEmail(e.target.value)}
|
||||
className="w-full px-3 py-2.5 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Company</label>
|
||||
<input
|
||||
type="text"
|
||||
value={clientCompany}
|
||||
onChange={(e) => setClientCompany(e.target.value)}
|
||||
className="w-full px-3 py-2.5 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-purple-100 dark:bg-purple-900/40 rounded-2xl mb-4">
|
||||
<MessageSquare className="w-8 h-8 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Communication Style</h2>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-2">Tell us how you communicate so AI emails match your voice.</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Tone</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{TONES.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
onClick={() => setTone(t.value)}
|
||||
className={cn(
|
||||
'p-3 rounded-xl border text-left transition-all',
|
||||
tone === t.value
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 ring-2 ring-blue-200 dark:ring-blue-800'
|
||||
: 'border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500'
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">{t.label}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{t.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Greeting</label>
|
||||
<input
|
||||
type="text"
|
||||
value={greeting}
|
||||
onChange={(e) => setGreeting(e.target.value)}
|
||||
placeholder="e.g., Hi, Hello, Dear"
|
||||
className="w-full px-3 py-2.5 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Sign-off</label>
|
||||
<input
|
||||
type="text"
|
||||
value={signoff}
|
||||
onChange={(e) => setSignoff(e.target.value)}
|
||||
placeholder="e.g., Best regards, Cheers, Warm wishes"
|
||||
className="w-full px-3 py-2.5 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-amber-100 dark:bg-amber-900/40 rounded-2xl mb-4">
|
||||
<Compass className="w-8 h-8 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100">You're All Set!</h2>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-2">Here's what you can do with NetworkCRM.</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{TOUR_FEATURES.map((feature) => (
|
||||
<div key={feature.title} className="flex items-start gap-3 p-3 bg-slate-50 dark:bg-slate-700/50 rounded-xl">
|
||||
<span className="text-2xl flex-shrink-0">{feature.icon}</span>
|
||||
<div>
|
||||
<p className="font-semibold text-sm text-slate-900 dark:text-slate-100">{feature.title}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{feature.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 sm:px-10 py-4 border-t border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={saving}
|
||||
className="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{step > 0 && (
|
||||
<button
|
||||
onClick={() => setStep(step - 1)}
|
||||
className="flex items-center gap-1 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"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1 px-5 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 font-medium"
|
||||
>
|
||||
{saving && <LoadingSpinner />}
|
||||
{step === STEPS.length - 1 ? 'Get Started' : 'Next'}
|
||||
{step < STEPS.length - 1 && <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -620,6 +620,55 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Tags Management
|
||||
async getTags(): Promise<{ name: string; count: number }[]> {
|
||||
return this.fetch('/tags');
|
||||
}
|
||||
|
||||
async renameTag(oldName: string, newName: string): Promise<{ success: boolean; updated: number }> {
|
||||
return this.fetch('/tags/rename', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ oldName, newName }),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTag(name: string): Promise<{ success: boolean; removed: number }> {
|
||||
return this.fetch(`/tags/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async mergeTags(sourceTags: string[], targetTag: string): Promise<{ success: boolean; updated: number }> {
|
||||
return this.fetch('/tags/merge', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sourceTags, targetTag }),
|
||||
});
|
||||
}
|
||||
|
||||
// Onboarding
|
||||
async getOnboardingStatus(): Promise<{ onboardingComplete: boolean }> {
|
||||
return this.fetch('/profile/onboarding-status');
|
||||
}
|
||||
|
||||
async completeOnboarding(): Promise<{ success: boolean }> {
|
||||
return this.fetch('/profile/complete-onboarding', { method: 'POST' });
|
||||
}
|
||||
|
||||
// Paginated clients
|
||||
async getClientsPaginated(params?: { search?: string; tag?: string; page?: number; limit?: number }): Promise<{
|
||||
data: Client[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.search) searchParams.set('search', params.search);
|
||||
if (params?.tag) searchParams.set('tag', params.tag);
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
if (params?.limit) searchParams.set('limit', String(params.limit));
|
||||
const query = searchParams.toString();
|
||||
return this.fetch(`/clients${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
async exportClientsCSV(): Promise<void> {
|
||||
const token = this.getToken();
|
||||
const headers: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
|
||||
@@ -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
329
src/pages/TagsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user