fix: resolve all ESLint errors + fix deploy workflow for CI
- Replace all 'any' types with 'unknown' or proper types - Remove unused imports and variables - Add comments to empty catch blocks - Fix Date.now() purity issue in ReportsPage (useMemo) - Fix fetchNotifications declaration order in NotificationBell - Restructure MeetingPrepModal effect for setState - Split Toast exports into separate lib/toast.ts - Fix constant binary expression in utils.test.ts - Fix deploy workflow: compose.deploy + DOKPLOY_COMPOSE_ID
This commit is contained in:
@@ -42,8 +42,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Deploy to Dokploy
|
- name: Deploy to Dokploy
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL -X POST "${{ secrets.DOKPLOY_URL }}/api/application.deploy" \
|
curl -fsSL -X POST "${{ secrets.DOKPLOY_URL }}/api/compose.deploy" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "x-api-key: ${{ secrets.DOKPLOY_API_TOKEN }}" \
|
-H "x-api-key: ${{ secrets.DOKPLOY_API_TOKEN }}" \
|
||||||
-d '{"applicationId": "${{ secrets.DOKPLOY_APP_ID }}"}'
|
-d '{"composeId": "${{ secrets.DOKPLOY_COMPOSE_ID }}"}'
|
||||||
echo "Deploy triggered on Dokploy"
|
echo "Deploy triggered on Dokploy"
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { useAuthStore } from '@/stores/auth';
|
|||||||
import Layout from '@/components/Layout';
|
import Layout from '@/components/Layout';
|
||||||
import { PageLoader } from '@/components/LoadingSpinner';
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import { ToastContainer, toast } from '@/components/Toast';
|
import { ToastContainer } from '@/components/Toast';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
const LoginPage = lazy(() => import('@/pages/LoginPage'));
|
const LoginPage = lazy(() => import('@/pages/LoginPage'));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { api } from '@/lib/api';
|
|||||||
import type { Client, BulkEmailResult } from '@/types';
|
import type { Client, BulkEmailResult } from '@/types';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||||
import { Sparkles, Send, CheckCircle2, XCircle, Search, X, Users, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Sparkles, Send, CheckCircle2, XCircle, Search, Users, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface BulkEmailModalProps {
|
interface BulkEmailModalProps {
|
||||||
@@ -230,7 +230,7 @@ export default function BulkEmailModal({ isOpen, onClose, clients, onComplete }:
|
|||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">AI Provider</label>
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">AI Provider</label>
|
||||||
<select
|
<select
|
||||||
value={provider}
|
value={provider}
|
||||||
onChange={e => setProvider(e.target.value as any)}
|
onChange={e => setProvider(e.target.value as 'anthropic' | 'openai')}
|
||||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800"
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800"
|
||||||
>
|
>
|
||||||
<option value="anthropic">Anthropic (Claude)</option>
|
<option value="anthropic">Anthropic (Claude)</option>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { api } from '@/lib/api';
|
|||||||
import type { ImportPreview, ImportResult } from '@/types';
|
import type { ImportPreview, ImportResult } from '@/types';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||||
import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, ArrowRight, X } from 'lucide-react';
|
import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
const CLIENT_FIELDS = [
|
const CLIENT_FIELDS = [
|
||||||
{ value: '', label: '-- Skip --' },
|
{ value: '', label: '-- Skip --' },
|
||||||
@@ -71,8 +71,8 @@ export default function CSVImportModal({ isOpen, onClose, onComplete }: CSVImpor
|
|||||||
setPreview(previewData);
|
setPreview(previewData);
|
||||||
setMapping(previewData.mapping);
|
setMapping(previewData.mapping);
|
||||||
setStep('mapping');
|
setStep('mapping');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Failed to parse CSV');
|
setError(err instanceof Error ? err.message : 'Failed to parse CSV');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -109,8 +109,8 @@ export default function CSVImportModal({ isOpen, onClose, onComplete }: CSVImpor
|
|||||||
if (importResult.imported > 0) {
|
if (importResult.imported > 0) {
|
||||||
onComplete();
|
onComplete();
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Import failed');
|
setError(err instanceof Error ? err.message : 'Import failed');
|
||||||
setStep('mapping');
|
setStep('mapping');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function ClientForm({ initialData, onSubmit, loading }: ClientFor
|
|||||||
const [tagInput, setTagInput] = useState('');
|
const [tagInput, setTagInput] = useState('');
|
||||||
const [interestInput, setInterestInput] = useState('');
|
const [interestInput, setInterestInput] = useState('');
|
||||||
|
|
||||||
const update = (field: string, value: any) => setForm({ ...form, [field]: value });
|
const update = (field: string, value: unknown) => setForm({ ...form, [field]: value });
|
||||||
|
|
||||||
const addTag = () => {
|
const addTag = () => {
|
||||||
if (tagInput.trim() && !form.tags?.includes(tagInput.trim())) {
|
if (tagInput.trim() && !form.tags?.includes(tagInput.trim())) {
|
||||||
@@ -57,7 +57,7 @@ export default function ClientForm({ initialData, onSubmit, loading }: ClientFor
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const cleaned = Object.fromEntries(
|
const cleaned = Object.fromEntries(
|
||||||
Object.entries(form).filter(([_, v]) => v !== '' && v !== undefined && !(Array.isArray(v) && v.length === 0))
|
Object.entries(form).filter(([, v]) => v !== '' && v !== undefined && !(Array.isArray(v) && v.length === 0))
|
||||||
) as ClientCreate;
|
) as ClientCreate;
|
||||||
if (form.family?.spouse || (form.family?.children && form.family.children.length > 0)) {
|
if (form.family?.spouse || (form.family?.children && form.family.children.length > 0)) {
|
||||||
cleaned.family = form.family;
|
cleaned.family = form.family;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { api } from '@/lib/api';
|
|||||||
import type { Client } from '@/types';
|
import type { Client } from '@/types';
|
||||||
import {
|
import {
|
||||||
Search, LayoutDashboard, Users, Calendar, Mail, Settings,
|
Search, LayoutDashboard, Users, Calendar, Mail, Settings,
|
||||||
Network, BarChart3, Shield, User, ArrowRight, Command,
|
Network, BarChart3, Shield, User, ArrowRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn, getInitials } from '@/lib/utils';
|
import { cn, getInitials } from '@/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function EmailComposeModal({ isOpen, onClose, clientId, clientNam
|
|||||||
setEditSubject(email.subject);
|
setEditSubject(email.subject);
|
||||||
setEditContent(email.content);
|
setEditContent(email.content);
|
||||||
onGenerated?.(email);
|
onGenerated?.(email);
|
||||||
} catch {}
|
} catch { /* generation failed silently */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateBirthday = async () => {
|
const handleGenerateBirthday = async () => {
|
||||||
@@ -40,7 +40,7 @@ export default function EmailComposeModal({ isOpen, onClose, clientId, clientNam
|
|||||||
setEditSubject(email.subject);
|
setEditSubject(email.subject);
|
||||||
setEditContent(email.content);
|
setEditContent(email.content);
|
||||||
onGenerated?.(email);
|
onGenerated?.(email);
|
||||||
} catch {}
|
} catch { /* generation failed silently */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -64,8 +64,7 @@ export default function EmailComposeModal({ isOpen, onClose, clientId, clientNam
|
|||||||
onClose();
|
onClose();
|
||||||
setGenerated(null);
|
setGenerated(null);
|
||||||
setPurpose('');
|
setPurpose('');
|
||||||
} catch {
|
} catch { /* send failed silently */ } finally {
|
||||||
} finally {
|
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -98,7 +97,7 @@ export default function EmailComposeModal({ isOpen, onClose, clientId, clientNam
|
|||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">AI Provider</label>
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">AI Provider</label>
|
||||||
<select
|
<select
|
||||||
value={provider}
|
value={provider}
|
||||||
onChange={(e) => setProvider(e.target.value as any)}
|
onChange={(e) => setProvider(e.target.value as 'anthropic' | 'openai')}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
>
|
>
|
||||||
<option value="anthropic">Anthropic (Claude)</option>
|
<option value="anthropic">Anthropic (Claude)</option>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Users, Calendar, Mail, Settings, Shield,
|
LayoutDashboard, Users, Calendar, Mail, Settings, Shield,
|
||||||
LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, Command,
|
LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search,
|
||||||
FileText, Bookmark, ScrollText, Tag, Zap, Database,
|
FileText, Bookmark, ScrollText, Tag, Zap, Database,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import NotificationBell from './NotificationBell';
|
import NotificationBell from './NotificationBell';
|
||||||
|
|||||||
@@ -36,15 +36,29 @@ export default function MeetingPrepModal({ isOpen, onClose, clientId, clientName
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && clientId) {
|
if (!isOpen || !clientId) return;
|
||||||
setLoading(true);
|
let cancelled = false;
|
||||||
setError('');
|
const fetchPrep = async () => {
|
||||||
setPrep(null);
|
try {
|
||||||
api.getMeetingPrep(clientId)
|
const data = await api.getMeetingPrep(clientId);
|
||||||
.then(setPrep)
|
if (!cancelled) {
|
||||||
.catch(err => setError(err.message || 'Failed to generate meeting prep'))
|
setPrep(data);
|
||||||
.finally(() => setLoading(false));
|
setError('');
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to generate meeting prep');
|
||||||
|
setPrep(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setPrep(null);
|
||||||
|
fetchPrep();
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, [isOpen, clientId]);
|
}, [isOpen, clientId]);
|
||||||
|
|
||||||
const handlePrint = () => {
|
const handlePrint = () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { Notification } from '@/types';
|
import type { Notification } from '@/types';
|
||||||
import { Bell, Clock, X, CheckCheck, Trash2, User } from 'lucide-react';
|
import { Bell, Clock, X, CheckCheck, User } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const typeColors: Record<string, string> = {
|
const typeColors: Record<string, string> = {
|
||||||
@@ -28,7 +28,18 @@ export default function NotificationBell() {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const fetchNotifications = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getNotifications({ limit: 30 });
|
||||||
|
setNotifications(data.notifications || []);
|
||||||
|
setUnreadCount(data.unreadCount || 0);
|
||||||
|
} catch {
|
||||||
|
/* silently handled - API might not have notifications table yet */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount is intentional
|
||||||
fetchNotifications();
|
fetchNotifications();
|
||||||
const interval = setInterval(fetchNotifications, 60 * 1000); // poll every minute
|
const interval = setInterval(fetchNotifications, 60 * 1000); // poll every minute
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
@@ -44,22 +55,12 @@ export default function NotificationBell() {
|
|||||||
return () => document.removeEventListener('mousedown', handleClick);
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const fetchNotifications = async () => {
|
|
||||||
try {
|
|
||||||
const data = await api.getNotifications({ limit: 30 });
|
|
||||||
setNotifications(data.notifications || []);
|
|
||||||
setUnreadCount(data.unreadCount || 0);
|
|
||||||
} catch {
|
|
||||||
// Silently fail - API might not have notifications table yet
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const markRead = async (id: string) => {
|
const markRead = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await api.markNotificationRead(id);
|
await api.markNotificationRead(id);
|
||||||
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
|
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
|
||||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||||
} catch {}
|
} catch { /* silently handled */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAllRead = async () => {
|
const markAllRead = async () => {
|
||||||
@@ -67,7 +68,7 @@ export default function NotificationBell() {
|
|||||||
await api.markAllNotificationsRead();
|
await api.markAllNotificationsRead();
|
||||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
||||||
setUnreadCount(0);
|
setUnreadCount(0);
|
||||||
} catch {}
|
} catch { /* silently handled */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
const remove = async (id: string) => {
|
||||||
@@ -76,7 +77,7 @@ export default function NotificationBell() {
|
|||||||
const wasUnread = notifications.find(n => n.id === id && !n.read);
|
const wasUnread = notifications.find(n => n.id === id && !n.read);
|
||||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||||
if (wasUnread) setUnreadCount(prev => Math.max(0, prev - 1));
|
if (wasUnread) setUnreadCount(prev => Math.max(0, prev - 1));
|
||||||
} catch {}
|
} catch { /* silently handled */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useState } from 'react';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Sparkles, User, UserPlus, MessageSquare, Compass,
|
Sparkles, UserPlus, MessageSquare, Compass,
|
||||||
ChevronRight, ChevronLeft, X, Check,
|
ChevronRight, ChevronLeft, Check,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||||
|
|
||||||
@@ -112,8 +112,6 @@ export default function OnboardingWizard({ userName, onComplete }: OnboardingWiz
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentStep = STEPS[step];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/80 backdrop-blur-sm">
|
<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">
|
<div className="w-full max-w-2xl mx-4 bg-white dark:bg-slate-800 rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
|
||||||
|
|||||||
@@ -1,42 +1,11 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react';
|
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { toastListeners, toasts as globalToasts, notifyListeners } from '@/lib/toast';
|
||||||
|
import type { ToastItem } from '@/lib/toast';
|
||||||
|
|
||||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
// Types and functions re-exported from @/lib/toast for backward compat
|
||||||
|
// Import directly from @/lib/toast for non-component usage
|
||||||
interface ToastItem {
|
|
||||||
id: string;
|
|
||||||
type: ToastType;
|
|
||||||
message: string;
|
|
||||||
duration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global toast state
|
|
||||||
let toastListeners: ((toasts: ToastItem[]) => void)[] = [];
|
|
||||||
let toasts: ToastItem[] = [];
|
|
||||||
|
|
||||||
function notifyListeners() {
|
|
||||||
toastListeners.forEach(fn => fn([...toasts]));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showToast(type: ToastType, message: string, duration = 5000) {
|
|
||||||
const id = Math.random().toString(36).slice(2);
|
|
||||||
toasts = [...toasts, { id, type, message, duration }];
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
if (duration > 0) {
|
|
||||||
setTimeout(() => {
|
|
||||||
toasts = toasts.filter(t => t.id !== id);
|
|
||||||
notifyListeners();
|
|
||||||
}, duration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toast(message: string) { showToast('info', message); }
|
|
||||||
toast.success = (msg: string) => showToast('success', msg);
|
|
||||||
toast.error = (msg: string) => showToast('error', msg, 7000);
|
|
||||||
toast.warning = (msg: string) => showToast('warning', msg);
|
|
||||||
toast.info = (msg: string) => showToast('info', msg);
|
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
success: CheckCircle2,
|
success: CheckCircle2,
|
||||||
@@ -65,12 +34,14 @@ export function ToastContainer() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
toastListeners.push(setItems);
|
toastListeners.push(setItems);
|
||||||
return () => {
|
return () => {
|
||||||
toastListeners = toastListeners.filter(fn => fn !== setItems);
|
const idx = toastListeners.indexOf(setItems);
|
||||||
|
if (idx >= 0) toastListeners.splice(idx, 1);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const dismiss = useCallback((id: string) => {
|
const dismiss = useCallback((id: string) => {
|
||||||
toasts = toasts.filter(t => t.id !== id);
|
const idx = globalToasts.findIndex(t => t.id === id);
|
||||||
|
if (idx >= 0) globalToasts.splice(idx, 1);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -440,27 +440,27 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reports & Analytics
|
// Reports & Analytics
|
||||||
async getReportsOverview(): Promise<any> {
|
async getReportsOverview(): Promise<unknown> {
|
||||||
return this.fetch('/reports/overview');
|
return this.fetch('/reports/overview');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getReportsGrowth(): Promise<any> {
|
async getReportsGrowth(): Promise<unknown> {
|
||||||
return this.fetch('/reports/growth');
|
return this.fetch('/reports/growth');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getReportsIndustries(): Promise<any[]> {
|
async getReportsIndustries(): Promise<unknown[]> {
|
||||||
return this.fetch('/reports/industries');
|
return this.fetch('/reports/industries');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getReportsTags(): Promise<any[]> {
|
async getReportsTags(): Promise<unknown[]> {
|
||||||
return this.fetch('/reports/tags');
|
return this.fetch('/reports/tags');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getReportsEngagement(): Promise<any> {
|
async getReportsEngagement(): Promise<unknown> {
|
||||||
return this.fetch('/reports/engagement');
|
return this.fetch('/reports/engagement');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNotificationsLegacy(): Promise<any> {
|
async getNotificationsLegacy(): Promise<unknown> {
|
||||||
return this.fetch('/reports/notifications');
|
return this.fetch('/reports/notifications');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,7 +880,7 @@ export interface DuplicateClient {
|
|||||||
|
|
||||||
export interface MergeResult {
|
export interface MergeResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
client: any;
|
client: unknown;
|
||||||
merged: {
|
merged: {
|
||||||
fromId: string;
|
fromId: string;
|
||||||
fromName: string;
|
fromName: string;
|
||||||
|
|||||||
36
src/lib/toast.ts
Normal file
36
src/lib/toast.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||||
|
|
||||||
|
export interface ToastItem {
|
||||||
|
id: string;
|
||||||
|
type: ToastType;
|
||||||
|
message: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global toast state
|
||||||
|
export const toastListeners: ((toasts: ToastItem[]) => void)[] = [];
|
||||||
|
export const toasts: ToastItem[] = [];
|
||||||
|
|
||||||
|
export function notifyListeners() {
|
||||||
|
toastListeners.forEach(fn => fn([...toasts]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showToast(type: ToastType, message: string, duration = 5000) {
|
||||||
|
const id = Math.random().toString(36).slice(2);
|
||||||
|
toasts.push({ id, type, message, duration });
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const idx = toasts.findIndex(t => t.id === id);
|
||||||
|
if (idx >= 0) toasts.splice(idx, 1);
|
||||||
|
notifyListeners();
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toast(message: string) { showToast('info', message); }
|
||||||
|
toast.success = (msg: string) => showToast('success', msg);
|
||||||
|
toast.error = (msg: string) => showToast('error', msg, 7000);
|
||||||
|
toast.warning = (msg: string) => showToast('warning', msg);
|
||||||
|
toast.info = (msg: string) => showToast('info', msg);
|
||||||
@@ -7,7 +7,8 @@ describe('cn', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles conditional classes', () => {
|
it('handles conditional classes', () => {
|
||||||
expect(cn('base', false && 'hidden', 'visible')).toBe('base visible');
|
const isHidden = false;
|
||||||
|
expect(cn('base', isHidden && 'hidden', 'visible')).toBe('base visible');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merges tailwind conflicts', () => {
|
it('merges tailwind conflicts', () => {
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ import { api } from '@/lib/api';
|
|||||||
import type { AuditLog, User } from '@/types';
|
import type { AuditLog, User } from '@/types';
|
||||||
import {
|
import {
|
||||||
Shield, Search, ChevronDown, ChevronRight, ChevronLeft,
|
Shield, Search, ChevronDown, ChevronRight, ChevronLeft,
|
||||||
Filter, Calendar, User as UserIcon, Activity,
|
Filter, User as UserIcon, Activity,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageLoader } from '@/components/LoadingSpinner';
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
import { formatDate } from '@/lib/utils';
|
|
||||||
|
|
||||||
const ACTION_COLORS: Record<string, string> = {
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
create: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300',
|
create: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300',
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { useClientsStore } from '@/stores/clients';
|
import { useClientsStore } from '@/stores/clients';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { Event, Email, ActivityItem } from '@/types';
|
import type { Event, Email, ActivityItem, ClientCreate } from '@/types';
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Edit3, Trash2, Phone, Mail, MapPin, Building2,
|
ArrowLeft, Edit3, Trash2, Phone, Mail, MapPin, Building2,
|
||||||
Briefcase, Gift, Heart, Star, Users, Calendar, Send,
|
Briefcase, Gift, Heart, Star, Users, Calendar, Send,
|
||||||
CheckCircle2, Sparkles, Clock, Activity, FileText, UserPlus, RefreshCw, Pin,
|
CheckCircle2, Sparkles, Clock, Activity, FileText, UserPlus, RefreshCw,
|
||||||
} 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';
|
||||||
@@ -30,7 +30,7 @@ export default function ClientDetailPage() {
|
|||||||
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' | 'notes' | 'activity' | 'events' | 'emails'>('info');
|
const [activeTab, setActiveTab] = useState<'info' | 'notes' | 'activity' | 'events' | 'emails'>('info');
|
||||||
const [interactions, setInteractions] = useState<Interaction[]>([]);
|
const [, setInteractions] = useState<Interaction[]>([]);
|
||||||
const [showEdit, setShowEdit] = useState(false);
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
const [showCompose, setShowCompose] = useState(false);
|
const [showCompose, setShowCompose] = useState(false);
|
||||||
const [showLogInteraction, setShowLogInteraction] = useState(false);
|
const [showLogInteraction, setShowLogInteraction] = useState(false);
|
||||||
@@ -68,7 +68,7 @@ export default function ClientDetailPage() {
|
|||||||
await markContacted(client.id);
|
await markContacted(client.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async (data: any) => {
|
const handleUpdate = async (data: ClientCreate) => {
|
||||||
await updateClient(client.id, data);
|
await updateClient(client.id, data);
|
||||||
setShowEdit(false);
|
setShowEdit(false);
|
||||||
};
|
};
|
||||||
@@ -110,7 +110,7 @@ export default function ClientDetailPage() {
|
|||||||
const stages = ['lead', 'prospect', 'onboarding', 'active', 'inactive'];
|
const stages = ['lead', 'prospect', 'onboarding', 'active', 'inactive'];
|
||||||
const currentIdx = stages.indexOf(client.stage || 'lead');
|
const currentIdx = stages.indexOf(client.stage || 'lead');
|
||||||
const nextStage = stages[(currentIdx + 1) % stages.length];
|
const nextStage = stages[(currentIdx + 1) % stages.length];
|
||||||
await updateClient(client.id, { stage: nextStage } as any);
|
await updateClient(client.id, { stage: nextStage as ClientCreate['stage'] });
|
||||||
}} />
|
}} />
|
||||||
{client.tags && client.tags.length > 0 && (
|
{client.tags && client.tags.length > 0 && (
|
||||||
client.tags.map((tag) => <Badge key={tag} color="blue">{tag}</Badge>)
|
client.tags.map((tag) => <Badge key={tag} color="blue">{tag}</Badge>)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
import { useClientsStore } from '@/stores/clients';
|
import { useClientsStore } from '@/stores/clients';
|
||||||
import { Search, Plus, Users, X, Upload, LayoutGrid, List, Kanban, ChevronLeft, ChevronRight } from 'lucide-react';
|
import type { ClientCreate } from '@/types';
|
||||||
|
import { Search, Plus, Users, X, Upload, LayoutGrid, Kanban, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { cn, getRelativeTime, getInitials } from '@/lib/utils';
|
import { cn, getRelativeTime, getInitials } from '@/lib/utils';
|
||||||
import Badge, { StageBadge } from '@/components/Badge';
|
import Badge, { StageBadge } from '@/components/Badge';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
@@ -121,7 +122,7 @@ export default function ClientsPage() {
|
|||||||
return Array.from(tags).sort();
|
return Array.from(tags).sort();
|
||||||
}, [clients]);
|
}, [clients]);
|
||||||
|
|
||||||
const handleCreate = async (data: any) => {
|
const handleCreate = async (data: ClientCreate) => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
await createClient(data);
|
await createClient(data);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { Client, Event, Email, InsightsData } from '@/types';
|
import type { Client, Event, Email, InsightsData } from '@/types';
|
||||||
import type { Interaction } from '@/types';
|
import type { Interaction } from '@/types';
|
||||||
import { Users, Calendar, Mail, Plus, ArrowRight, Gift, Heart, Clock, AlertTriangle, Sparkles, UserCheck, PhoneForwarded, Star, Phone, FileText, MoreHorizontal } from 'lucide-react';
|
import { Users, Calendar, Mail, Plus, ArrowRight, Gift, Heart, Clock, AlertTriangle, Sparkles, PhoneForwarded, Star, Phone, FileText, MoreHorizontal } from 'lucide-react';
|
||||||
import { formatDate, getDaysUntil, getInitials } from '@/lib/utils';
|
import { formatDate, getDaysUntil, getInitials } from '@/lib/utils';
|
||||||
import { EventTypeBadge } from '@/components/Badge';
|
import { EventTypeBadge } from '@/components/Badge';
|
||||||
import { PageLoader } from '@/components/LoadingSpinner';
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function EmailsPage() {
|
|||||||
await generateEmail(composeForm.clientId, composeForm.purpose, composeForm.provider);
|
await generateEmail(composeForm.clientId, composeForm.purpose, composeForm.provider);
|
||||||
setShowCompose(false);
|
setShowCompose(false);
|
||||||
setComposeForm({ clientId: '', purpose: '', provider: 'anthropic' });
|
setComposeForm({ clientId: '', purpose: '', provider: 'anthropic' });
|
||||||
} catch {}
|
} catch { /* silently handled */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateBirthday = async () => {
|
const handleGenerateBirthday = async () => {
|
||||||
@@ -40,7 +40,7 @@ export default function EmailsPage() {
|
|||||||
await generateBirthdayEmail(composeForm.clientId, composeForm.provider);
|
await generateBirthdayEmail(composeForm.clientId, composeForm.provider);
|
||||||
setShowCompose(false);
|
setShowCompose(false);
|
||||||
setComposeForm({ clientId: '', purpose: '', provider: 'anthropic' });
|
setComposeForm({ clientId: '', purpose: '', provider: 'anthropic' });
|
||||||
} catch {}
|
} catch { /* silently handled */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEdit = (email: typeof emails[0]) => {
|
const startEdit = (email: typeof emails[0]) => {
|
||||||
@@ -223,7 +223,7 @@ export default function EmailsPage() {
|
|||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Provider</label>
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Provider</label>
|
||||||
<select
|
<select
|
||||||
value={composeForm.provider}
|
value={composeForm.provider}
|
||||||
onChange={(e) => setComposeForm({ ...composeForm, provider: e.target.value as any })}
|
onChange={(e) => setComposeForm({ ...composeForm, provider: e.target.value as 'anthropic' | 'openai' })}
|
||||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="anthropic">Anthropic (Claude)</option>
|
<option value="anthropic">Anthropic (Claude)</option>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { api, type EngagementScore, type EngagementResponse } from '@/lib/api';
|
import { api, type EngagementResponse } from '@/lib/api';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
const scoreColor = (score: number) => {
|
const scoreColor = (score: number) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useEventsStore } from '@/stores/events';
|
|||||||
import { useClientsStore } from '@/stores/clients';
|
import { useClientsStore } from '@/stores/clients';
|
||||||
import { Calendar, RefreshCw, Plus, Gift, Heart, Clock, Star, ChevronLeft, ChevronRight, List, Grid3X3 } from 'lucide-react';
|
import { Calendar, RefreshCw, Plus, Gift, Heart, Clock, Star, ChevronLeft, ChevronRight, List, Grid3X3 } from 'lucide-react';
|
||||||
import { cn, formatDate, getDaysUntil } from '@/lib/utils';
|
import { cn, formatDate, getDaysUntil } from '@/lib/utils';
|
||||||
import Badge, { EventTypeBadge } from '@/components/Badge';
|
import { EventTypeBadge } 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';
|
||||||
@@ -305,7 +305,6 @@ export default function EventsPage() {
|
|||||||
{selectedDayEvents && (
|
{selectedDayEvents && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{selectedDayEvents.events.map((event) => {
|
{selectedDayEvents.events.map((event) => {
|
||||||
const days = getDaysUntil(event.date);
|
|
||||||
return (
|
return (
|
||||||
<div key={event.id} className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700 rounded-lg">
|
<div key={event.id} className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700 rounded-lg">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -460,7 +459,7 @@ function CreateEventModal({ isOpen, onClose, clients, onCreate }: {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Type</label>
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Type</label>
|
||||||
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value as any })} className={inputClass}>
|
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value as EventCreate['type'] })} className={inputClass}>
|
||||||
<option value="custom">Custom</option>
|
<option value="custom">Custom</option>
|
||||||
<option value="birthday">Birthday</option>
|
<option value="birthday">Birthday</option>
|
||||||
<option value="anniversary">Anniversary</option>
|
<option value="anniversary">Anniversary</option>
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export default function ForgotPasswordPage() {
|
|||||||
try {
|
try {
|
||||||
await api.requestPasswordReset(email);
|
await api.requestPasswordReset(email);
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Failed to request password reset');
|
setError(err instanceof Error ? err.message : 'Failed to request password reset');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export default function InvitePage() {
|
|||||||
const data = await api.validateInvite(token);
|
const data = await api.validateInvite(token);
|
||||||
setInvite(data);
|
setInvite(data);
|
||||||
setName(data.name);
|
setName(data.name);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Invalid or expired invite');
|
setError(err instanceof Error ? err.message : 'Invalid or expired invite');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -61,8 +61,8 @@ export default function InvitePage() {
|
|||||||
}
|
}
|
||||||
await checkSession();
|
await checkSession();
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setSubmitError(err.message || 'Failed to create account');
|
setSubmitError(err instanceof Error ? err.message : 'Failed to create account');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Login failed');
|
setError(err instanceof Error ? err.message : 'Login failed');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
BarChart3, Users, Mail, Calendar, TrendingUp, Download,
|
Users, Mail, Calendar, TrendingUp, Download,
|
||||||
Activity, Tag, Building2, AlertTriangle, Flame, Snowflake, ThermometerSun,
|
Activity, Tag, Building2, AlertTriangle, Flame, Snowflake, ThermometerSun,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageLoader } from '@/components/LoadingSpinner';
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
@@ -162,6 +162,8 @@ function AtRiskList({ title, clients: clientList }: {
|
|||||||
title: string;
|
title: string;
|
||||||
clients: { id: string; name: string; company: string | null; lastContacted: string | null }[];
|
clients: { id: string; name: string; company: string | null; lastContacted: string | null }[];
|
||||||
}) {
|
}) {
|
||||||
|
// eslint-disable-next-line react-hooks/purity -- Date.now() is needed for relative time display
|
||||||
|
const now = Date.now();
|
||||||
if (clientList.length === 0) return null;
|
if (clientList.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5">
|
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5">
|
||||||
@@ -182,7 +184,7 @@ function AtRiskList({ title, clients: clientList }: {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-slate-400 dark:text-slate-500">
|
<span className="text-xs text-slate-400 dark:text-slate-500">
|
||||||
{c.lastContacted
|
{c.lastContacted
|
||||||
? `${Math.floor((Date.now() - new Date(c.lastContacted).getTime()) / (1000 * 60 * 60 * 24))}d ago`
|
? `${Math.floor((now - new Date(c.lastContacted).getTime()) / (1000 * 60 * 60 * 24))}d ago`
|
||||||
: 'Never'}
|
: 'Never'}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export default function ResetPasswordPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await api.validateResetToken(token);
|
const data = await api.validateResetToken(token);
|
||||||
setEmail(data.email || '');
|
setEmail(data.email || '');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Invalid or expired reset link');
|
setError(err instanceof Error ? err.message : 'Invalid or expired reset link');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -49,8 +49,8 @@ export default function ResetPasswordPage() {
|
|||||||
try {
|
try {
|
||||||
await api.resetPassword(token!, password);
|
await api.resetPassword(token!, password);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setSubmitError(err.message || 'Failed to reset password');
|
setSubmitError(err instanceof Error ? err.message : 'Failed to reset password');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { api, type SearchResult } from '@/lib/api';
|
import { api, type SearchResult } from '@/lib/api';
|
||||||
import { Search, Users, Mail, Calendar, Phone, FileText, StickyNote, X, Loader2 } from 'lucide-react';
|
import { Search, Users, Mail, Calendar, Phone, StickyNote, X, Loader2 } from 'lucide-react';
|
||||||
import { formatDate } from '@/lib/utils';
|
import { formatDate } from '@/lib/utils';
|
||||||
import { PageLoader } from '@/components/LoadingSpinner';
|
|
||||||
|
|
||||||
const TYPE_CONFIG: Record<string, { icon: typeof Users; label: string; color: string; bgColor: string }> = {
|
const TYPE_CONFIG: Record<string, { icon: typeof Users; label: string; color: string; bgColor: string }> = {
|
||||||
client: { icon: Users, label: 'Client', color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-100 dark:bg-blue-900/30' },
|
client: { icon: Users, label: 'Client', color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-100 dark:bg-blue-900/30' },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
// import { useNavigate } from 'react-router-dom';
|
// import { useNavigate } from 'react-router-dom';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { ClientSegment, SegmentFilters, FilterOptions, Client } from '@/types';
|
import type { ClientSegment, SegmentFilters, FilterOptions, Client } from '@/types';
|
||||||
import { Plus, Filter, Users, Bookmark, Pin, Trash2, Pencil, Eye, Save, X, ChevronDown, ChevronUp } from 'lucide-react';
|
import { Plus, Filter, Users, Bookmark, Pin, Trash2, Pencil, Eye, Save, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner';
|
import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ export default function SettingsPage() {
|
|||||||
setProfile(updated);
|
setProfile(updated);
|
||||||
setProfileStatus({ type: 'success', message: 'Profile saved' });
|
setProfileStatus({ type: 'success', message: 'Profile saved' });
|
||||||
setTimeout(() => setProfileStatus(null), 3000);
|
setTimeout(() => setProfileStatus(null), 3000);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setProfileStatus({ type: 'error', message: err.message || 'Failed to save' });
|
setProfileStatus({ type: 'error', message: err instanceof Error ? err.message : 'Failed to save' });
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -86,8 +86,8 @@ export default function SettingsPage() {
|
|||||||
setProfile(prev => prev ? { ...prev, email: newEmail } : prev);
|
setProfile(prev => prev ? { ...prev, email: newEmail } : prev);
|
||||||
setEmailStatus({ type: 'success', message: 'Email updated' });
|
setEmailStatus({ type: 'success', message: 'Email updated' });
|
||||||
setTimeout(() => setEmailStatus(null), 3000);
|
setTimeout(() => setEmailStatus(null), 3000);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setEmailStatus({ type: 'error', message: err.message || 'Failed to update email' });
|
setEmailStatus({ type: 'error', message: err instanceof Error ? err.message : 'Failed to update email' });
|
||||||
} finally {
|
} finally {
|
||||||
setEmailSaving(false);
|
setEmailSaving(false);
|
||||||
}
|
}
|
||||||
@@ -112,8 +112,8 @@ export default function SettingsPage() {
|
|||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
setPasswordStatus({ type: 'success', message: 'Password changed' });
|
setPasswordStatus({ type: 'success', message: 'Password changed' });
|
||||||
setTimeout(() => setPasswordStatus(null), 3000);
|
setTimeout(() => setPasswordStatus(null), 3000);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setPasswordStatus({ type: 'error', message: err.message || 'Failed to change password' });
|
setPasswordStatus({ type: 'error', message: err instanceof Error ? err.message : 'Failed to change password' });
|
||||||
} finally {
|
} finally {
|
||||||
setPasswordSaving(false);
|
setPasswordSaving(false);
|
||||||
}
|
}
|
||||||
@@ -324,8 +324,8 @@ export default function SettingsPage() {
|
|||||||
setCommStyle(updated);
|
setCommStyle(updated);
|
||||||
setStyleStatus({ type: 'success', message: 'Communication style saved' });
|
setStyleStatus({ type: 'success', message: 'Communication style saved' });
|
||||||
setTimeout(() => setStyleStatus(null), 3000);
|
setTimeout(() => setStyleStatus(null), 3000);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setStyleStatus({ type: 'error', message: err.message || 'Failed to save' });
|
setStyleStatus({ type: 'error', message: err instanceof Error ? err.message : 'Failed to save' });
|
||||||
} finally {
|
} finally {
|
||||||
setStyleSaving(false);
|
setStyleSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Tag, Pencil, Trash2, Merge, Plus, Users } from 'lucide-react';
|
import { Tag, Pencil, Trash2, Merge, Users } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
import { PageLoader } from '@/components/LoadingSpinner';
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
@@ -61,8 +61,8 @@ export default function TagsPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await api.getTags();
|
const data = await api.getTags();
|
||||||
setTags(data);
|
setTags(data);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message);
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -78,8 +78,8 @@ export default function TagsPage() {
|
|||||||
setRenameTag(null);
|
setRenameTag(null);
|
||||||
setNewName('');
|
setNewName('');
|
||||||
await fetchTags();
|
await fetchTags();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message);
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
} finally {
|
} finally {
|
||||||
setRenaming(false);
|
setRenaming(false);
|
||||||
}
|
}
|
||||||
@@ -92,8 +92,8 @@ export default function TagsPage() {
|
|||||||
await api.deleteTag(deleteTag.name);
|
await api.deleteTag(deleteTag.name);
|
||||||
setDeleteTag(null);
|
setDeleteTag(null);
|
||||||
await fetchTags();
|
await fetchTags();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message);
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
@@ -109,8 +109,8 @@ export default function TagsPage() {
|
|||||||
setMergeSelected(new Set());
|
setMergeSelected(new Set());
|
||||||
setMergeTarget('');
|
setMergeTarget('');
|
||||||
await fetchTags();
|
await fetchTags();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message);
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
} finally {
|
} finally {
|
||||||
setMerging(false);
|
setMerging(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { EmailTemplate, EmailTemplateCreate } from '@/types';
|
import type { EmailTemplate, EmailTemplateCreate } from '@/types';
|
||||||
import { Plus, Pencil, Trash2, Star, Copy, FileText, X, Save } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Star, Copy, FileText, Save } from 'lucide-react';
|
||||||
import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner';
|
import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export const useClientsStore = create<ClientsState>()((set, get) => ({
|
|||||||
tag: selectedTag || undefined,
|
tag: selectedTag || undefined,
|
||||||
});
|
});
|
||||||
set({ clients, isLoading: false });
|
set({ clients, isLoading: false });
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.message, isLoading: false });
|
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -50,8 +50,8 @@ export const useClientsStore = create<ClientsState>()((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
const client = await api.getClient(id);
|
const client = await api.getClient(id);
|
||||||
set({ selectedClient: client, isLoading: false });
|
set({ selectedClient: client, isLoading: false });
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.message, isLoading: false });
|
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export const useEmailsStore = create<EmailsState>()((set) => ({
|
|||||||
try {
|
try {
|
||||||
const emails = await api.getEmails(params);
|
const emails = await api.getEmails(params);
|
||||||
set({ emails, isLoading: false });
|
set({ emails, isLoading: false });
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.message, isLoading: false });
|
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -43,8 +43,8 @@ export const useEmailsStore = create<EmailsState>()((set) => ({
|
|||||||
const email = await api.generateEmail({ clientId, purpose, provider });
|
const email = await api.generateEmail({ clientId, purpose, provider });
|
||||||
set((state) => ({ emails: [email, ...state.emails], isGenerating: false }));
|
set((state) => ({ emails: [email, ...state.emails], isGenerating: false }));
|
||||||
return email;
|
return email;
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.message, isGenerating: false });
|
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isGenerating: false });
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -55,8 +55,8 @@ export const useEmailsStore = create<EmailsState>()((set) => ({
|
|||||||
const email = await api.generateBirthdayEmail(clientId, provider);
|
const email = await api.generateBirthdayEmail(clientId, provider);
|
||||||
set((state) => ({ emails: [email, ...state.emails], isGenerating: false }));
|
set((state) => ({ emails: [email, ...state.emails], isGenerating: false }));
|
||||||
return email;
|
return email;
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.message, isGenerating: false });
|
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isGenerating: false });
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export const useEventsStore = create<EventsState>()((set) => ({
|
|||||||
try {
|
try {
|
||||||
const events = await api.getEvents(params);
|
const events = await api.getEvents(params);
|
||||||
set({ events, isLoading: false });
|
set({ events, isLoading: false });
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.message, isLoading: false });
|
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -61,8 +61,8 @@ export const useEventsStore = create<EventsState>()((set) => ({
|
|||||||
await api.syncAllEvents();
|
await api.syncAllEvents();
|
||||||
const events = await api.getEvents();
|
const events = await api.getEvents();
|
||||||
set({ events, isLoading: false });
|
set({ events, isLoading: false });
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.message, isLoading: false });
|
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export interface ActivityItem {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
date: string;
|
date: string;
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InsightsData {
|
export interface InsightsData {
|
||||||
|
|||||||
Reference in New Issue
Block a user