changes till 09/05/2026
New clients creation from opportunities, client conversion%, time taken for conversion, close the modal when touched outside it Client and company name separate, Demo becomes a separate activity, all changes done in mobile app as well 2) transfer of clients, demos followups negotiation etc scheduling, quote opportunity in place of enquiry, In opportunity new product add, existing dropdown, added option for adding documents on client creation and showing itmain
parent
3e8d86c980
commit
3bb4c35def
|
|
@ -14,7 +14,7 @@ import Reports from '@/components/Reports';
|
||||||
import OpportunityBoard from '@/components/OpportunityBoard';
|
import OpportunityBoard from '@/components/OpportunityBoard';
|
||||||
import TargetManager from '@/components/TargetManager';
|
import TargetManager from '@/components/TargetManager';
|
||||||
import IncentiveManager from '@/components/IncentiveManager';
|
import IncentiveManager from '@/components/IncentiveManager';
|
||||||
import FollowupsManager from '@/components/FollowupsManager';
|
import ActivitiesManager from '@/components/ActivitiesManager';
|
||||||
import FunnelAnalysisPage from '@/components/FunnelAnalysisPage';
|
import FunnelAnalysisPage from '@/components/FunnelAnalysisPage';
|
||||||
import CallLogs from '@/components/CallLogs';
|
import CallLogs from '@/components/CallLogs';
|
||||||
import Settings from '@/components/Settings';
|
import Settings from '@/components/Settings';
|
||||||
|
|
@ -53,7 +53,7 @@ export default function DashboardPage() {
|
||||||
{ id: 'incentives', label: 'Incentives', icon: TrendingUp },
|
{ id: 'incentives', label: 'Incentives', icon: TrendingUp },
|
||||||
{ id: 'reports', label: 'Reports', icon: BarChart },
|
{ id: 'reports', label: 'Reports', icon: BarChart },
|
||||||
...(user?.role === 'ADMIN' || user?.role === 'GENERAL_MANAGER' ? [{ id: 'targets', label: 'Targets', icon: Target }] : []),
|
...(user?.role === 'ADMIN' || user?.role === 'GENERAL_MANAGER' ? [{ id: 'targets', label: 'Targets', icon: Target }] : []),
|
||||||
{ id: 'followups', label: 'Follow-ups', icon: CalendarCheck },
|
{ id: 'activities', label: 'Activities', icon: CalendarCheck },
|
||||||
{ id: 'call-logs', label: 'Call Logs', icon: PhoneCall },
|
{ id: 'call-logs', label: 'Call Logs', icon: PhoneCall },
|
||||||
...(user?.role === 'ADMIN' || user?.role === 'GENERAL_MANAGER' ? [{ id: 'funnel-analysis', label: 'Funnel Analysis', icon: GitMerge }] : []),
|
...(user?.role === 'ADMIN' || user?.role === 'GENERAL_MANAGER' ? [{ id: 'funnel-analysis', label: 'Funnel Analysis', icon: GitMerge }] : []),
|
||||||
{ id: 'products', label: 'Products', icon: Package },
|
{ id: 'products', label: 'Products', icon: Package },
|
||||||
|
|
@ -102,8 +102,8 @@ export default function DashboardPage() {
|
||||||
return <IncentiveManager />;
|
return <IncentiveManager />;
|
||||||
case 'reports':
|
case 'reports':
|
||||||
return <Reports />;
|
return <Reports />;
|
||||||
case 'followups':
|
case 'activities':
|
||||||
return <div className="p-6"><FollowupsManager /></div>;
|
return <div className="p-6 h-full"><ActivitiesManager /></div>;
|
||||||
case 'call-logs':
|
case 'call-logs':
|
||||||
return <CallLogs />;
|
return <CallLogs />;
|
||||||
case 'funnel-analysis':
|
case 'funnel-analysis':
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,658 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import api from '../lib/axios';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { Calendar, User, Building2, Filter, CheckCircle2, Clock, AlertTriangle, RefreshCw, Presentation, FileText, MessageSquare, ListTodo } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Activity {
|
||||||
|
id: string;
|
||||||
|
type: 'FOLLOWUP' | 'DEMO' | 'QUOTE' | 'NEGOTIATION';
|
||||||
|
notes: string;
|
||||||
|
status: string;
|
||||||
|
date: string;
|
||||||
|
createdAt: string;
|
||||||
|
client?: { id: string; name: string; companyName?: string; files?: any[] };
|
||||||
|
user?: { id: string; name: string };
|
||||||
|
demoPersonName?: string;
|
||||||
|
demoContactDetails?: string;
|
||||||
|
keyQueries?: string;
|
||||||
|
objections?: string;
|
||||||
|
competitorMention?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterState {
|
||||||
|
userId: string;
|
||||||
|
clientId: string;
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
status: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivitiesManager({ initialClientId, initialOpportunityId }: { initialClientId?: string; initialOpportunityId?: string }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [activities, setActivities] = useState<Activity[]>([]);
|
||||||
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
|
const [clients, setClients] = useState<any[]>([]);
|
||||||
|
const [opportunities, setOpportunities] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [reassigning, setReassigning] = useState<string | null>(null);
|
||||||
|
const [reassignUserId, setReassignUserId] = useState('');
|
||||||
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
|
userId: '', clientId: initialClientId || '', dateFrom: '', dateTo: '', status: '', type: ''
|
||||||
|
});
|
||||||
|
const [feedbackActivity, setFeedbackActivity] = useState<Activity | null>(null);
|
||||||
|
const [demoFeedback, setDemoFeedback] = useState({
|
||||||
|
demoPersonName: '',
|
||||||
|
demoContactDetails: '',
|
||||||
|
keyQueries: '',
|
||||||
|
competitorMention: ''
|
||||||
|
});
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [newActivity, setNewActivity] = useState({
|
||||||
|
userId: user?.id || '',
|
||||||
|
clientId: initialClientId || '',
|
||||||
|
opportunityId: initialOpportunityId || '',
|
||||||
|
type: 'FOLLOWUP' as Activity['type'],
|
||||||
|
notes: '',
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
time: '10:00',
|
||||||
|
demoPersonName: '',
|
||||||
|
demoContactDetails: '',
|
||||||
|
keyQueries: '',
|
||||||
|
objections: '',
|
||||||
|
competitorMention: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.id && !newActivity.userId) {
|
||||||
|
setNewActivity(prev => ({ ...prev, userId: user.id }));
|
||||||
|
}
|
||||||
|
}, [user, newActivity.userId]);
|
||||||
|
|
||||||
|
const isAdminOrGM = ['ADMIN', 'GENERAL_MANAGER'].includes(user?.role || '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchActivities();
|
||||||
|
api.get('/users').then(r => setUsers(r.data)).catch(() => {});
|
||||||
|
api.get('/clients').then(r => setClients(r.data)).catch(() => {});
|
||||||
|
api.get('/opportunities').then(r => setOpportunities(r.data)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchActivities = async (f: FilterState = filters) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (f.userId) params.append('userId', f.userId);
|
||||||
|
if (f.clientId) params.append('clientId', f.clientId);
|
||||||
|
if (f.dateFrom) params.append('dateFrom', f.dateFrom);
|
||||||
|
if (f.dateTo) params.append('dateTo', f.dateTo);
|
||||||
|
if (f.status) params.append('status', f.status);
|
||||||
|
if (f.type) params.append('type', f.type);
|
||||||
|
const res = await api.get(`/followups?${params.toString()}`);
|
||||||
|
setActivities(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterChange = (key: keyof FilterState, value: string) => {
|
||||||
|
const updated = { ...filters, [key]: value };
|
||||||
|
setFilters(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => fetchActivities(filters);
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
const reset: FilterState = { userId: '', clientId: '', dateFrom: '', dateTo: '', status: '', type: '' };
|
||||||
|
setFilters(reset);
|
||||||
|
fetchActivities(reset);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkDone = async (activity: Activity) => {
|
||||||
|
if (activity.type === 'DEMO') {
|
||||||
|
setFeedbackActivity(activity);
|
||||||
|
setDemoFeedback({ demoPersonName: '', demoContactDetails: '', keyQueries: '', competitorMention: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.confirm('Mark this activity as DONE?')) return;
|
||||||
|
try {
|
||||||
|
await api.patch(`/followups/${activity.id}`, { status: 'DONE' });
|
||||||
|
setActivities(activities.map(a => a.id === activity.id ? { ...a, status: 'DONE' } : a));
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to update status.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitDemoFeedback = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!feedbackActivity) return;
|
||||||
|
|
||||||
|
if (!demoFeedback.demoPersonName || !demoFeedback.demoContactDetails) {
|
||||||
|
alert('Please provide Person Met and Contact Details.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.patch(`/followups/${feedbackActivity.id}`, {
|
||||||
|
status: 'DONE',
|
||||||
|
...demoFeedback
|
||||||
|
});
|
||||||
|
setActivities(activities.map(a => a.id === feedbackActivity.id ? { ...a, status: 'DONE', ...demoFeedback } : a));
|
||||||
|
setFeedbackActivity(null);
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to submit demo feedback.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReassign = async (id: string) => {
|
||||||
|
if (!reassignUserId) { alert('Please select a user to reassign to.'); return; }
|
||||||
|
try {
|
||||||
|
await api.patch(`/followups/${id}`, { userId: reassignUserId });
|
||||||
|
setReassigning(null);
|
||||||
|
setReassignUserId('');
|
||||||
|
fetchActivities(filters);
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to reassign task.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newActivity.clientId || !newActivity.userId || !newActivity.date) {
|
||||||
|
alert('Please fill in all required fields.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dateStr = `${newActivity.date}T${newActivity.time}:00`;
|
||||||
|
await api.post('/followups', {
|
||||||
|
...newActivity,
|
||||||
|
opportunityId: newActivity.opportunityId || null,
|
||||||
|
date: new Date(dateStr).toISOString(),
|
||||||
|
status: 'PENDING'
|
||||||
|
});
|
||||||
|
setIsCreateModalOpen(false);
|
||||||
|
setNewActivity({
|
||||||
|
userId: user?.id || '',
|
||||||
|
clientId: initialClientId || '',
|
||||||
|
opportunityId: initialOpportunityId || '',
|
||||||
|
type: 'FOLLOWUP',
|
||||||
|
notes: '',
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
time: '10:00',
|
||||||
|
demoPersonName: '',
|
||||||
|
demoContactDetails: '',
|
||||||
|
keyQueries: '',
|
||||||
|
objections: '',
|
||||||
|
competitorMention: ''
|
||||||
|
});
|
||||||
|
fetchActivities(filters);
|
||||||
|
alert('Activity scheduled successfully!');
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to create activity.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupByDate = (items: Activity[]) => {
|
||||||
|
const map: Record<string, Activity[]> = {};
|
||||||
|
items.forEach(a => {
|
||||||
|
const key = new Date(a.date).toLocaleDateString('en-IN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
if (!map[key]) map[key] = [];
|
||||||
|
map[key].push(a);
|
||||||
|
});
|
||||||
|
return Object.entries(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: activities.length,
|
||||||
|
pending: activities.filter(a => a.status === 'PENDING').length,
|
||||||
|
overdue: activities.filter(a => a.status === 'PENDING' && new Date(a.date) < today).length,
|
||||||
|
done: activities.filter(a => a.status === 'DONE').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeIcon = (type: Activity['type']) => {
|
||||||
|
switch(type) {
|
||||||
|
case 'DEMO': return <Presentation size={14} className="text-blue-500" />;
|
||||||
|
case 'QUOTE': return <FileText size={14} className="text-purple-500" />;
|
||||||
|
case 'NEGOTIATION': return <MessageSquare size={14} className="text-amber-500" />;
|
||||||
|
default: return <ListTodo size={14} className="text-indigo-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeBadge = (type: Activity['type']) => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
DEMO: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||||
|
QUOTE: 'bg-purple-100 text-purple-700 border-purple-200',
|
||||||
|
NEGOTIATION: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||||
|
FOLLOWUP: 'bg-indigo-100 text-indigo-700 border-indigo-200',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`text-[10px] font-black px-2 py-0.5 rounded border ${styles[type] || styles.FOLLOWUP}`}>
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white shadow-xl rounded-2xl overflow-hidden border border-gray-100 h-full flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-800">Activity Manager</h3>
|
||||||
|
<p className="text-sm text-gray-500">Track and manage all scheduled pipeline activities</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
className="bg-odoo-primary text-white px-4 py-2 rounded-xl text-sm font-black hover:shadow-lg transition-all flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>📅</span> Schedule Activity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Row */}
|
||||||
|
<div className="grid grid-cols-4 divide-x divide-gray-100 border-b border-gray-100 flex-shrink-0">
|
||||||
|
{[
|
||||||
|
{ label: 'Total', value: stats.total, icon: <Filter size={14}/>, color: 'text-gray-600', bg: 'bg-gray-50' },
|
||||||
|
{ label: 'Pending', value: stats.pending, icon: <Clock size={14}/>, color: 'text-amber-600', bg: 'bg-amber-50' },
|
||||||
|
{ label: 'Overdue', value: stats.overdue, icon: <AlertTriangle size={14}/>, color: 'text-red-600', bg: 'bg-red-50' },
|
||||||
|
{ label: 'Done', value: stats.done, icon: <CheckCircle2 size={14}/>, color: 'text-emerald-600', bg: 'bg-emerald-50' },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.label} className={`${s.bg} px-6 py-4 flex items-center gap-3`}>
|
||||||
|
<div className={`${s.color}`}>{s.icon}</div>
|
||||||
|
<div>
|
||||||
|
<div className={`text-2xl font-black ${s.color}`}>{s.value}</div>
|
||||||
|
<div className="text-xs text-gray-500 font-semibold">{s.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="px-6 py-4 bg-gray-50/50 border-b border-gray-100 flex-shrink-0">
|
||||||
|
<div className="flex flex-wrap gap-3 items-end">
|
||||||
|
<div className="flex-1 min-w-[160px]">
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 mb-1"><User size={10} className="inline mr-1"/>User</label>
|
||||||
|
<select
|
||||||
|
value={filters.userId}
|
||||||
|
onChange={e => handleFilterChange('userId', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white"
|
||||||
|
>
|
||||||
|
<option value="">All Users</option>
|
||||||
|
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{!initialClientId && (
|
||||||
|
<div className="flex-1 min-w-[160px]">
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 mb-1"><Building2 size={10} className="inline mr-1"/>Client</label>
|
||||||
|
<select
|
||||||
|
value={filters.clientId}
|
||||||
|
onChange={e => handleFilterChange('clientId', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white"
|
||||||
|
>
|
||||||
|
<option value="">All Clients</option>
|
||||||
|
{clients.map(c => <option key={c.id} value={c.id}>{c.companyName || c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-[140px]">
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 mb-1">From Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateFrom}
|
||||||
|
onChange={e => handleFilterChange('dateFrom', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[140px]">
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 mb-1">To Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateTo}
|
||||||
|
onChange={e => handleFilterChange('dateTo', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[120px]">
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 mb-1">Type</label>
|
||||||
|
<select value={filters.type} onChange={e => handleFilterChange('type', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="FOLLOWUP">Follow-up</option>
|
||||||
|
<option value="DEMO">Demo</option>
|
||||||
|
<option value="QUOTE">Quote</option>
|
||||||
|
<option value="NEGOTIATION">Negotiation</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[120px]">
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 mb-1">Status</label>
|
||||||
|
<select value={filters.status} onChange={e => handleFilterChange('status', e.target.value)}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white">
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="PENDING">Pending</option>
|
||||||
|
<option value="DONE">Done</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleApply} className="bg-odoo-primary text-white px-4 py-2 rounded-lg text-sm font-bold hover:bg-odoo-primary/90 transition-all">
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<button onClick={handleReset} className="border border-gray-200 text-gray-600 px-3 py-2 rounded-lg text-sm hover:bg-gray-100 transition-all">
|
||||||
|
<RefreshCw size={14}/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline View */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-8 custom-scrollbar">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-gray-400">Loading activities...</div>
|
||||||
|
) : activities.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-4xl mb-3">📭</p>
|
||||||
|
<p className="text-gray-500 font-semibold">No activities match these filters.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
groupByDate(activities).map(([dateLabel, items]) => {
|
||||||
|
const dateObj = new Date(items[0].date);
|
||||||
|
dateObj.setHours(0,0,0,0);
|
||||||
|
const isToday = dateObj.getTime() === today.getTime();
|
||||||
|
const isPast = dateObj < today;
|
||||||
|
return (
|
||||||
|
<div key={dateLabel}>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className={`text-xs font-black px-3 py-1 rounded-full uppercase tracking-wider ${isToday ? 'bg-odoo-primary text-white' : isPast ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{isToday ? '📅 Today' : isPast ? `⚠️ ${dateLabel}` : dateLabel}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 h-px bg-gray-100"/>
|
||||||
|
<span className="text-xs text-gray-400 font-semibold">{items.length} item{items.length !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map(a => (
|
||||||
|
<div key={a.id} className={`flex items-start gap-4 p-4 rounded-xl border transition-all ${a.status === 'DONE' ? 'bg-gray-50 border-gray-100 opacity-70' : isPast && a.status === 'PENDING' ? 'bg-red-50 border-red-200' : 'bg-white border-gray-200 hover:shadow-md'}`}>
|
||||||
|
<div className={`mt-1 flex-shrink-0 p-2 rounded-lg ${a.status === 'DONE' ? 'bg-emerald-100 text-emerald-600' : isPast ? 'bg-red-100 text-red-600' : 'bg-gray-100 text-gray-500'}`}>
|
||||||
|
{getTypeIcon(a.type)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||||
|
{a.client && <span className="text-sm font-bold text-gray-900">{a.client.companyName || a.client.name}</span>}
|
||||||
|
{getTypeBadge(a.type)}
|
||||||
|
{a.user && isAdminOrGM && <span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Assigned to {a.user.name}</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-700 leading-relaxed font-medium">{a.notes}</p>
|
||||||
|
{a.type === 'DEMO' && a.demoPersonName && (
|
||||||
|
<div className="mt-2 text-[12px] bg-blue-50/50 p-2 rounded border border-blue-100 text-blue-800 grid grid-cols-2 gap-x-4">
|
||||||
|
<p><span className="font-bold">Met:</span> {a.demoPersonName}</p>
|
||||||
|
<p><span className="font-bold">Contact:</span> {a.demoContactDetails}</p>
|
||||||
|
{a.competitorMention && <p className="col-span-2"><span className="font-bold">Competitor:</span> {a.competitorMention}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-gray-400 mt-2 font-bold uppercase tracking-widest">
|
||||||
|
🕐 {new Date(a.date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 flex flex-col items-end gap-2">
|
||||||
|
{a.status === 'DONE' ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-bold px-3 py-1 bg-emerald-100 text-emerald-700 rounded-full">
|
||||||
|
<CheckCircle2 size={11}/> Done
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleMarkDone(a)}
|
||||||
|
className="text-xs font-bold px-3 py-1.5 bg-odoo-primary text-white rounded-lg hover:bg-odoo-primary/90 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Mark Done
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isAdminOrGM && a.status !== 'DONE' && (
|
||||||
|
reassigning === a.id ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<select
|
||||||
|
value={reassignUserId}
|
||||||
|
onChange={e => setReassignUserId(e.target.value)}
|
||||||
|
className="text-xs p-1.5 border border-gray-300 rounded-lg outline-none bg-white"
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
<option value="">User...</option>
|
||||||
|
{users.filter(u => u.id !== a.user?.id).map(u => (
|
||||||
|
<option key={u.id} value={u.id}>{u.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => handleReassign(a.id)}
|
||||||
|
className="text-xs font-bold px-2 py-1.5 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-all"
|
||||||
|
>Go</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setReassigning(null); setReassignUserId(''); }}
|
||||||
|
className="text-xs px-2 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-100 transition-all"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => { setReassigning(a.id); setReassignUserId(''); }}
|
||||||
|
className="text-xs font-semibold px-3 py-1 border border-amber-300 text-amber-700 bg-amber-50 rounded-lg hover:bg-amber-100 transition-all"
|
||||||
|
>
|
||||||
|
↩ Reassign
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Activity Modal */}
|
||||||
|
{isCreateModalOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[999] flex items-center justify-center p-4"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setIsCreateModalOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-xl overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||||
|
<div className="bg-odoo-primary px-6 py-4 text-white flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-bold">Schedule Pipeline Activity</h3>
|
||||||
|
<button onClick={() => setIsCreateModalOpen(false)} className="hover:bg-white/20 p-1 rounded-lg">✕</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleCreateSubmit} className="p-6 overflow-y-auto max-h-[80vh] custom-scrollbar">
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Activity Type</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{[
|
||||||
|
{ id: 'FOLLOWUP', label: 'Follow-up', icon: <ListTodo size={16}/>, color: 'indigo' },
|
||||||
|
{ id: 'DEMO', label: 'Demo', icon: <Presentation size={16}/>, color: 'blue' },
|
||||||
|
{ id: 'QUOTE', label: 'Quote', icon: <FileText size={16}/>, color: 'purple' },
|
||||||
|
{ id: 'NEGOTIATION', label: 'Negotiate', icon: <MessageSquare size={16}/>, color: 'amber' },
|
||||||
|
].map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNewActivity({ ...newActivity, type: t.id as any })}
|
||||||
|
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${newActivity.type === t.id ? `border-${t.color}-500 bg-${t.color}-50 text-${t.color}-700` : 'border-gray-100 bg-gray-50 text-gray-500 hover:border-gray-200'}`}
|
||||||
|
>
|
||||||
|
{t.icon}
|
||||||
|
<span className="text-[10px] font-black mt-1 uppercase tracking-tight">{t.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={initialClientId || newActivity.type === 'QUOTE' ? 'col-span-2' : ''}>
|
||||||
|
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">
|
||||||
|
{newActivity.type === 'QUOTE' ? 'Link to Opportunity *' : 'Client *'}
|
||||||
|
</label>
|
||||||
|
{newActivity.type === 'QUOTE' ? (
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={newActivity.opportunityId}
|
||||||
|
onChange={e => {
|
||||||
|
const opp = opportunities.find(o => o.id === e.target.value);
|
||||||
|
setNewActivity({
|
||||||
|
...newActivity,
|
||||||
|
opportunityId: e.target.value,
|
||||||
|
clientId: opp?.clientId || newActivity.clientId
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary"
|
||||||
|
>
|
||||||
|
<option value="">Select Opportunity...</option>
|
||||||
|
{opportunities.map(o => (
|
||||||
|
<option key={o.id} value={o.id}>{o.title} ({o.client?.name})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
disabled={!!initialClientId}
|
||||||
|
value={newActivity.clientId}
|
||||||
|
onChange={e => setNewActivity({ ...newActivity, clientId: e.target.value })}
|
||||||
|
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<option value="">Select Client...</option>
|
||||||
|
{clients.map(c => <option key={c.id} value={c.id}>{c.companyName || c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Assign To *</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={newActivity.userId}
|
||||||
|
onChange={e => setNewActivity({ ...newActivity, userId: e.target.value })}
|
||||||
|
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<option value="">Assign User...</option>
|
||||||
|
<option value={user?.id}>Myself ({user?.name})</option>
|
||||||
|
{users.filter(u => u.id !== user?.id).map(u => (
|
||||||
|
<option key={u.id} value={u.id}>{u.name} ({u.role})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Date *</label>
|
||||||
|
<input
|
||||||
|
type="date" required
|
||||||
|
value={newActivity.date}
|
||||||
|
onChange={e => setNewActivity({ ...newActivity, date: e.target.value })}
|
||||||
|
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Time *</label>
|
||||||
|
<input
|
||||||
|
type="time" required
|
||||||
|
value={newActivity.time}
|
||||||
|
onChange={e => setNewActivity({ ...newActivity, time: e.target.value })}
|
||||||
|
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Demo details moved to Opportunity update modal, removed from scheduling */}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Notes / Task Description *</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
value={newActivity.notes}
|
||||||
|
onChange={e => setNewActivity({ ...newActivity, notes: e.target.value })}
|
||||||
|
placeholder="What needs to be done?"
|
||||||
|
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary h-24 resize-none text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCreateModalOpen(false)}
|
||||||
|
className="flex-1 px-4 py-3 border border-gray-200 rounded-xl font-bold text-gray-600 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-[2] bg-odoo-primary text-white px-4 py-3 rounded-xl font-black shadow-lg hover:shadow-odoo-primary/20 hover:scale-[1.02] transition-all"
|
||||||
|
>
|
||||||
|
Schedule Activity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Demo Feedback Modal */}
|
||||||
|
{feedbackActivity && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-[9999] backdrop-blur-sm transition-all duration-300">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden flex flex-col max-h-[90vh]">
|
||||||
|
<div className="bg-odoo-primary px-6 py-4 flex justify-between items-center text-white shrink-0">
|
||||||
|
<h3 className="font-bold text-lg">Demo Feedback</h3>
|
||||||
|
<button onClick={() => setFeedbackActivity(null)} className="hover:bg-white/20 p-1.5 rounded-lg transition-colors">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submitDemoFeedback} className="p-6 overflow-y-auto space-y-4 shrink-0">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Person Met *</label>
|
||||||
|
<input
|
||||||
|
type="text" required
|
||||||
|
value={demoFeedback.demoPersonName}
|
||||||
|
onChange={e => setDemoFeedback({ ...demoFeedback, demoPersonName: e.target.value })}
|
||||||
|
className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm"
|
||||||
|
placeholder="e.g. John Doe (CTO)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Contact Details *</label>
|
||||||
|
<input
|
||||||
|
type="text" required
|
||||||
|
value={demoFeedback.demoContactDetails}
|
||||||
|
onChange={e => setDemoFeedback({ ...demoFeedback, demoContactDetails: e.target.value })}
|
||||||
|
className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm"
|
||||||
|
placeholder="Phone or Email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Key Queries</label>
|
||||||
|
<textarea
|
||||||
|
value={demoFeedback.keyQueries}
|
||||||
|
onChange={e => setDemoFeedback({ ...demoFeedback, keyQueries: e.target.value })}
|
||||||
|
className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm resize-none"
|
||||||
|
rows={3} placeholder="What did they ask about?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Competitor Mentioned</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={demoFeedback.competitorMention}
|
||||||
|
onChange={e => setDemoFeedback({ ...demoFeedback, competitorMention: e.target.value })}
|
||||||
|
className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm"
|
||||||
|
placeholder="e.g. Salesforce, Zoho"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="w-full bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-3 px-4 rounded-xl shadow-lg shadow-emerald-500/30 transition-all mt-4">
|
||||||
|
Submit & Mark Done
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,13 +9,19 @@ import ClientModal from './ClientModal';
|
||||||
interface Client {
|
interface Client {
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
companyName?: string;
|
||||||
|
contactName?: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
landmark?: string;
|
landmark?: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
closingProbability?: number;
|
||||||
|
expectedClosingTimeframe?: string;
|
||||||
assignedTo?: string;
|
assignedTo?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
isDemoDone?: boolean;
|
||||||
|
files?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Enquiry {
|
interface Enquiry {
|
||||||
|
|
@ -34,7 +40,7 @@ interface Followup {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = ['LEAD', 'PROSPECT', 'CUSTOMER', 'CLOSED'];
|
const STATUS_OPTIONS = ['LEAD', 'QUALITY', 'POTENTIAL', 'SALES', 'CLOSED'];
|
||||||
|
|
||||||
export default function ClientList() {
|
export default function ClientList() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
@ -94,6 +100,28 @@ export default function ClientList() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getFileUrl = (url: string) => {
|
||||||
|
if (!url) return '#';
|
||||||
|
if (url.includes('fake-storage.com')) {
|
||||||
|
alert('This file was attached using the old mock system and is no longer available. Please delete it and re-upload the document.');
|
||||||
|
return '#';
|
||||||
|
}
|
||||||
|
if (url.startsWith('http')) return url;
|
||||||
|
|
||||||
|
let base = api.defaults.baseURL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
// If base is relative or just a path, construct absolute URL to API port (3000)
|
||||||
|
if (!base.startsWith('http')) {
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3001';
|
||||||
|
// Replace frontend port 3001 with backend port 3000
|
||||||
|
base = origin.replace(':3001', ':3000') + (base.startsWith('/') ? base : '/' + base);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
|
||||||
|
const normalizedUrl = url.startsWith('/') ? url : `/${url}`;
|
||||||
|
return `${normalizedBase}${normalizedUrl}`;
|
||||||
|
};
|
||||||
|
|
||||||
const handleClientClick = async (client: Client) => {
|
const handleClientClick = async (client: Client) => {
|
||||||
setSelectedClient(client);
|
setSelectedClient(client);
|
||||||
setEnquiries([]);
|
setEnquiries([]);
|
||||||
|
|
@ -266,8 +294,9 @@ export default function ClientList() {
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50 sticky top-0 z-10">
|
<thead className="bg-gray-50 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Name</th>
|
<th className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Company</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Contact</th>
|
<th className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Contact Person</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Contact Details</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Status</th>
|
<th className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">Action</th>
|
<th className="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -278,21 +307,27 @@ export default function ClientList() {
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="h-10 w-10 rounded-full bg-odoo-primary/10 flex items-center justify-center text-odoo-primary font-bold mr-3">
|
<div className="h-10 w-10 rounded-full bg-odoo-primary/10 flex items-center justify-center text-odoo-primary font-bold mr-3">
|
||||||
{client.name.charAt(0).toUpperCase()}
|
{(client.companyName || client.name).charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-gray-900 group-hover:text-odoo-primary transition-colors">
|
||||||
|
{client.companyName || 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-semibold text-gray-900 group-hover:text-odoo-primary transition-colors">{client.name}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
|
||||||
|
{client.contactName || client.name}
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm text-gray-600">{client.email || 'N/A'}</div>
|
<div className="text-xs text-gray-600">{client.email || 'N/A'}</div>
|
||||||
<div className="text-sm text-gray-500">{client.phone}</div>
|
<div className="text-xs text-gray-500">{client.phone}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border
|
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border
|
||||||
${client.status === 'CUSTOMER' ? 'bg-odoo-secondary/10 text-odoo-secondary border-odoo-secondary/20' :
|
${client.status === 'SALES' ? 'bg-odoo-secondary/10 text-odoo-secondary border-odoo-secondary/20' :
|
||||||
client.status === 'LEAD' ? 'bg-odoo-primary/10 text-odoo-primary border-odoo-primary/20' :
|
client.status === 'LEAD' ? 'bg-odoo-primary/10 text-odoo-primary border-odoo-primary/20' :
|
||||||
client.status === 'PROSPECT' ? 'bg-amber-50 text-amber-700 border-amber-200' :
|
client.status === 'QUALITY' ? 'bg-amber-50 text-amber-700 border-amber-200' :
|
||||||
'bg-gray-100 text-gray-800 border-gray-200'}`}>
|
client.status === 'POTENTIAL' ? 'bg-blue-50 text-blue-700 border-blue-200' :
|
||||||
|
'bg-gray-100 text-gray-800 border-gray-200'}`}>
|
||||||
{client.status}
|
{client.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -320,12 +355,18 @@ export default function ClientList() {
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 bg-gray-50 p-6 rounded-xl border border-gray-100">
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 bg-gray-50 p-6 rounded-xl border border-gray-100">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold text-gray-800 mb-1">{selectedClient.name}</h2>
|
<h2 className="text-3xl font-bold text-gray-800 mb-1">{selectedClient.companyName || selectedClient.name}</h2>
|
||||||
|
<p className="text-lg font-medium text-gray-600 mb-2">👤 {selectedClient.contactName || 'N/A'}</p>
|
||||||
<div className="flex items-center space-x-4 text-sm text-gray-500">
|
<div className="flex items-center space-x-4 text-sm text-gray-500">
|
||||||
<span>{selectedClient.email}</span>
|
<span>{selectedClient.email}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{selectedClient.phone}</span>
|
<span>{selectedClient.phone}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedClient.isDemoDone && (
|
||||||
|
<div className="mt-3 flex items-center gap-1.5 px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-xs font-bold w-fit">
|
||||||
|
<FileText size={14} /> DEMO COMPLETED
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 md:mt-0">
|
<div className="mt-4 md:mt-0">
|
||||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Current Status</label>
|
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Current Status</label>
|
||||||
|
|
@ -438,6 +479,40 @@ export default function ClientList() {
|
||||||
|
|
||||||
{/* Right Col: Info & Enquiries */}
|
{/* Right Col: Info & Enquiries */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Attachments Card - Moved to top for visibility */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="font-bold text-gray-800">Attachments</h3>
|
||||||
|
<span className="text-[10px] font-black bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">{(selectedClient as any).files?.length || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{((selectedClient as any).files && (selectedClient as any).files.length > 0) ? (
|
||||||
|
(selectedClient as any).files.map((file: any, idx: number) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between p-2 bg-gray-50 rounded-lg border border-gray-100 hover:bg-white hover:shadow-sm transition-all group">
|
||||||
|
<div className="flex items-center space-x-3 overflow-hidden">
|
||||||
|
<div className="p-1.5 bg-white rounded border border-gray-100 text-gray-400 group-hover:text-odoo-primary group-hover:border-odoo-primary/20 transition-colors">
|
||||||
|
<FileText size={14} />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="text-xs font-bold text-gray-700 truncate max-w-[120px]">{file.name}</div>
|
||||||
|
<div className="text-[9px] text-gray-400 font-bold uppercase tracking-wider">{(file.size / 1024).toFixed(0)} KB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => window.open(getFileUrl(file.url), '_blank')}
|
||||||
|
className="p-1.5 hover:bg-odoo-primary/10 rounded text-gray-400 hover:text-odoo-primary transition-all"
|
||||||
|
title="Open Document"
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-400 italic text-center py-2">No attachments found.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||||
<h3 className="font-bold text-gray-800 mb-4">Past Enquiries</h3>
|
<h3 className="font-bold text-gray-800 mb-4">Past Enquiries</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -497,12 +572,10 @@ export default function ClientList() {
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
📍
|
📍
|
||||||
{/* Assuming address field exists on Client interface, might need to add it if commonly used */}
|
|
||||||
{(selectedClient as any).address || (selectedClient as any).location || 'No address provided'}
|
{(selectedClient as any).address || (selectedClient as any).location || 'No address provided'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,28 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { X, Save, Trash2, Loader2, User } from 'lucide-react';
|
import { X, Save, Trash2, Loader2, User, Paperclip, Plus, Trash } from 'lucide-react';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import api from '../lib/axios';
|
import api from '../lib/axios';
|
||||||
|
import ActivitiesManager from './ActivitiesManager';
|
||||||
|
|
||||||
interface Client {
|
interface Client {
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
companyName?: string;
|
||||||
|
contactName?: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
landmark?: string;
|
landmark?: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
closingProbability?: number;
|
||||||
|
expectedClosingTimeframe?: string;
|
||||||
assignedTo?: string;
|
assignedTo?: string;
|
||||||
user?: { id: string, name: string };
|
user?: { id: string, name: string };
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
isDemoDone?: boolean;
|
||||||
|
files?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientModalProps {
|
interface ClientModalProps {
|
||||||
|
|
@ -26,23 +33,31 @@ interface ClientModalProps {
|
||||||
client: Client | null;
|
client: Client | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = ['LEAD', 'PROSPECT', 'CUSTOMER', 'CLOSED'];
|
const STATUS_OPTIONS = ['LEAD', 'QUALITY', 'POTENTIAL', 'SALES', 'CLOSED'];
|
||||||
|
|
||||||
export default function ClientModal({ isOpen, onClose, onSave, onDelete, client }: ClientModalProps) {
|
export default function ClientModal({ isOpen, onClose, onSave, onDelete, client }: ClientModalProps) {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const [formData, setFormData] = useState<Client>({
|
const [formData, setFormData] = useState<Client>({
|
||||||
name: '',
|
name: '',
|
||||||
|
companyName: '',
|
||||||
|
contactName: '',
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
address: '',
|
address: '',
|
||||||
landmark: '',
|
landmark: '',
|
||||||
status: 'LEAD',
|
status: 'LEAD',
|
||||||
|
closingProbability: 0,
|
||||||
|
expectedClosingTimeframe: '',
|
||||||
assignedTo: '',
|
assignedTo: '',
|
||||||
|
isDemoDone: false,
|
||||||
|
files: [],
|
||||||
});
|
});
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<any[]>([]);
|
||||||
const [users, setUsers] = useState<any[]>([]);
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<'details' | 'activities'>('details');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
|
@ -53,12 +68,7 @@ export default function ClientModal({ isOpen, onClose, onSave, onDelete, client
|
||||||
const fetchAssignees = async () => {
|
const fetchAssignees = async () => {
|
||||||
setLoadingUsers(true);
|
setLoadingUsers(true);
|
||||||
try {
|
try {
|
||||||
let endpoint = '/users';
|
const response = await api.get('/users');
|
||||||
if (currentUser?.role === 'MANAGER') {
|
|
||||||
endpoint = '/users/me/subordinates';
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await api.get(endpoint);
|
|
||||||
setUsers(response.data);
|
setUsers(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch users', error);
|
console.error('Failed to fetch users', error);
|
||||||
|
|
@ -71,39 +81,112 @@ export default function ClientModal({ isOpen, onClose, onSave, onDelete, client
|
||||||
if (client) {
|
if (client) {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: client.name || '',
|
name: client.name || '',
|
||||||
|
companyName: client.companyName || '',
|
||||||
|
contactName: client.contactName || '',
|
||||||
email: client.email || '',
|
email: client.email || '',
|
||||||
phone: client.phone || '',
|
phone: client.phone || '',
|
||||||
address: client.address || '',
|
address: client.address || '',
|
||||||
landmark: client.landmark || '',
|
landmark: client.landmark || '',
|
||||||
status: client.status || 'LEAD',
|
status: client.status || 'LEAD',
|
||||||
|
closingProbability: client.closingProbability || 0,
|
||||||
|
expectedClosingTimeframe: client.expectedClosingTimeframe || '',
|
||||||
assignedTo: client.assignedTo || '',
|
assignedTo: client.assignedTo || '',
|
||||||
|
isDemoDone: !!client.isDemoDone,
|
||||||
|
files: (client as any).files || [],
|
||||||
});
|
});
|
||||||
|
setSelectedFiles((client as any).files || []);
|
||||||
|
setActiveTab('details');
|
||||||
} else {
|
} else {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
|
companyName: '',
|
||||||
|
contactName: '',
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
address: '',
|
address: '',
|
||||||
landmark: '',
|
landmark: '',
|
||||||
status: 'LEAD',
|
status: 'LEAD',
|
||||||
|
closingProbability: 0,
|
||||||
|
expectedClosingTimeframe: '',
|
||||||
assignedTo: currentUser?.id || '',
|
assignedTo: currentUser?.id || '',
|
||||||
|
isDemoDone: false,
|
||||||
|
files: [],
|
||||||
});
|
});
|
||||||
|
setSelectedFiles([]);
|
||||||
|
setActiveTab('details');
|
||||||
}
|
}
|
||||||
}, [client, isOpen]);
|
}, [client, isOpen]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
const files = Array.from(e.target.files);
|
||||||
|
const newFiles = [];
|
||||||
|
|
||||||
|
// Set some visual feedback if possible, or just loop
|
||||||
|
for (const file of files) {
|
||||||
|
const uploadFormData = new FormData();
|
||||||
|
uploadFormData.append('file', file);
|
||||||
|
try {
|
||||||
|
const res = await api.post('/upload', uploadFormData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
});
|
||||||
|
newFiles.push({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
url: res.data.url
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Upload failed', err);
|
||||||
|
alert(`Failed to upload ${file.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedFiles([...selectedFiles, ...newFiles]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileUrl = (url: string) => {
|
||||||
|
if (!url) return '#';
|
||||||
|
if (url.includes('fake-storage.com')) {
|
||||||
|
alert('This file was attached using the old mock system and is no longer available. Please delete it and re-upload the document.');
|
||||||
|
return '#';
|
||||||
|
}
|
||||||
|
if (url.startsWith('http')) return url;
|
||||||
|
|
||||||
|
let base = api.defaults.baseURL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
if (!base.startsWith('http')) {
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3001';
|
||||||
|
base = origin.replace(':3001', ':3000') + (base.startsWith('/') ? base : '/' + base);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
|
||||||
|
const normalizedUrl = url.startsWith('/') ? url : `/${url}`;
|
||||||
|
return `${normalizedBase}${normalizedUrl}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
setSelectedFiles(selectedFiles.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await onSave({ ...formData, id: client?.id });
|
const finalData = {
|
||||||
|
...formData,
|
||||||
|
id: client?.id,
|
||||||
|
name: formData.name || formData.companyName || formData.contactName || '',
|
||||||
|
files: selectedFiles
|
||||||
|
};
|
||||||
|
await onSave(finalData);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
alert('Failed to save client');
|
alert('Failed to save client');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(true);
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -125,7 +208,12 @@ export default function ClientModal({ isOpen, onClose, onSave, onDelete, client
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[10001] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
|
<div
|
||||||
|
className="fixed inset-0 z-[10001] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="bg-white rounded-[24px] shadow-2xl w-full max-w-lg overflow-hidden border border-gray-100 flex flex-col max-h-[90vh]">
|
<div className="bg-white rounded-[24px] shadow-2xl w-full max-w-lg overflow-hidden border border-gray-100 flex flex-col max-h-[90vh]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-100 flex justify-between items-center">
|
<div className="px-6 py-4 bg-gray-50 border-b border-gray-100 flex justify-between items-center">
|
||||||
|
|
@ -138,101 +226,217 @@ export default function ClientModal({ isOpen, onClose, onSave, onDelete, client
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{client && (
|
||||||
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto custom-scrollbar space-y-4">
|
<div className="flex border-b border-gray-100 bg-white">
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<button
|
||||||
<div>
|
type="button"
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Company / Contact Name *</label>
|
onClick={() => setActiveTab('details')}
|
||||||
<input
|
className={`flex-1 py-3 text-xs font-black transition-all border-b-2 uppercase tracking-widest ${activeTab === 'details' ? 'border-odoo-primary text-odoo-primary bg-gray-50/50' : 'border-transparent text-gray-400 hover:text-gray-600'}`}
|
||||||
type="text"
|
>
|
||||||
required
|
Client Details
|
||||||
value={formData.name}
|
</button>
|
||||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
<button
|
||||||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all"
|
type="button"
|
||||||
placeholder="Enter name"
|
onClick={() => setActiveTab('activities')}
|
||||||
/>
|
className={`flex-1 py-3 text-xs font-black transition-all border-b-2 uppercase tracking-widest ${activeTab === 'activities' ? 'border-odoo-primary text-odoo-primary bg-gray-50/50' : 'border-transparent text-gray-400 hover:text-gray-600'}`}
|
||||||
</div>
|
>
|
||||||
<div>
|
Activities & Logs
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Phone Number *</label>
|
</button>
|
||||||
<input
|
</div>
|
||||||
type="text"
|
)}
|
||||||
required
|
|
||||||
value={formData.phone}
|
{activeTab === 'details' ? (
|
||||||
onChange={e => setFormData({ ...formData, phone: e.target.value })}
|
<form id="client-form" onSubmit={handleSubmit} className="p-6 overflow-y-auto custom-scrollbar space-y-4">
|
||||||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all"
|
<div className="grid grid-cols-2 gap-4">
|
||||||
placeholder="Enter phone"
|
<div className="col-span-2">
|
||||||
/>
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Company Name</label>
|
||||||
</div>
|
<input
|
||||||
<div>
|
type="text"
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Email Address</label>
|
value={formData.companyName}
|
||||||
<input
|
onChange={e => setFormData({ ...formData, companyName: e.target.value })}
|
||||||
type="email"
|
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary outline-none transition-all"
|
||||||
value={formData.email}
|
placeholder="Enter company name"
|
||||||
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
/>
|
||||||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all"
|
</div>
|
||||||
placeholder="Enter email"
|
<div className="col-span-2">
|
||||||
/>
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Contact Person Name *</label>
|
||||||
</div>
|
<input
|
||||||
<div>
|
type="text"
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Street Address</label>
|
required
|
||||||
<textarea
|
value={formData.contactName}
|
||||||
value={formData.address}
|
onChange={e => setFormData({ ...formData, contactName: e.target.value })}
|
||||||
onChange={e => setFormData({ ...formData, address: e.target.value })}
|
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary outline-none transition-all"
|
||||||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all"
|
placeholder="Enter contact name"
|
||||||
placeholder="Enter address"
|
/>
|
||||||
rows={2}
|
</div>
|
||||||
/>
|
<div>
|
||||||
</div>
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Phone Number *</label>
|
||||||
<div>
|
<input
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Landmark / Area</label>
|
type="text"
|
||||||
<input
|
required
|
||||||
type="text"
|
value={formData.phone}
|
||||||
value={formData.landmark}
|
onChange={e => setFormData({ ...formData, phone: e.target.value })}
|
||||||
onChange={e => setFormData({ ...formData, landmark: e.target.value })}
|
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary outline-none transition-all"
|
||||||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all"
|
placeholder="Enter phone"
|
||||||
placeholder="e.g. Near Metro Station"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Email Address</label>
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Lifecycle Status</label>
|
<input
|
||||||
<div className="flex flex-wrap gap-2 mt-1">
|
type="email"
|
||||||
{STATUS_OPTIONS.map(status => (
|
value={formData.email}
|
||||||
<button
|
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||||
key={status}
|
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary outline-none transition-all"
|
||||||
type="button"
|
placeholder="Enter email"
|
||||||
onClick={() => setFormData({ ...formData, status })}
|
/>
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all border ${formData.status === status
|
</div>
|
||||||
? 'bg-odoo-primary text-white border-odoo-primary'
|
<div>
|
||||||
: 'bg-white text-gray-500 border-gray-200 hover:border-odoo-primary hover:text-odoo-primary'
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Street Address</label>
|
||||||
}`}
|
<textarea
|
||||||
>
|
value={formData.address}
|
||||||
{status}
|
onChange={e => setFormData({ ...formData, address: e.target.value })}
|
||||||
</button>
|
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary outline-none transition-all"
|
||||||
))}
|
placeholder="Enter address"
|
||||||
</div>
|
rows={2}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Landmark / Area</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.landmark}
|
||||||
|
onChange={e => setFormData({ ...formData, landmark: e.target.value })}
|
||||||
|
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary outline-none transition-all"
|
||||||
|
placeholder="e.g. Near Metro Station"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Lifecycle Status</label>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
|
{STATUS_OPTIONS.map(status => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, status })}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all border ${formData.status === status
|
||||||
|
? 'bg-odoo-primary text-white border-odoo-primary'
|
||||||
|
: 'bg-white text-gray-500 border-gray-200 hover:border-odoo-primary hover:text-odoo-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Closing Probability (%)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={formData.closingProbability === 0 ? '' : formData.closingProbability}
|
||||||
|
onChange={e => setFormData({ ...formData, closingProbability: e.target.value === '' ? 0 : parseInt(e.target.value) })}
|
||||||
|
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary outline-none transition-all"
|
||||||
|
placeholder="e.g. 75"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Expected Closing Timeframe</label>
|
||||||
|
<select
|
||||||
|
value={formData.expectedClosingTimeframe}
|
||||||
|
onChange={e => setFormData({ ...formData, expectedClosingTimeframe: e.target.value })}
|
||||||
|
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary outline-none transition-all"
|
||||||
|
>
|
||||||
|
<option value="">Select timeframe</option>
|
||||||
|
<option value="IMMEDIATE">Immediate (Within 1 week)</option>
|
||||||
|
<option value="SHORT_TERM">Short Term (1-4 weeks)</option>
|
||||||
|
<option value="MEDIUM_TERM">Medium Term (1-3 months)</option>
|
||||||
|
<option value="LONG_TERM">Long Term (3+ months)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{(currentUser?.role === 'ADMIN' || currentUser?.role === 'MANAGER') && (
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Assigned To</label>
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Assigned To</label>
|
||||||
<select
|
<select
|
||||||
value={formData.assignedTo}
|
value={formData.assignedTo}
|
||||||
onChange={e => setFormData({ ...formData, assignedTo: e.target.value })}
|
onChange={e => setFormData({ ...formData, assignedTo: e.target.value })}
|
||||||
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all"
|
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary outline-none transition-all"
|
||||||
>
|
>
|
||||||
<option value="">Select teammate</option>
|
<option value="">Select teammate</option>
|
||||||
<option value={currentUser.id}>Myself ({currentUser.name})</option>
|
<option value={currentUser?.id}>Myself ({currentUser?.name})</option>
|
||||||
{users.filter(u => u.id !== currentUser.id).map(u => (
|
{users.filter(u => u.id !== currentUser?.id).map(u => (
|
||||||
<option key={u.id} value={u.id}>{u.name} ({u.role})</option>
|
<option key={u.id} value={u.id}>{u.name} ({u.role})</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-[10px] text-gray-400 mt-1">
|
|
||||||
{currentUser?.role === 'MANAGER' ? 'Only subordinates are shown.' : 'All users are shown.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 bg-blue-50/50 p-4 rounded-xl border border-blue-100 mt-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isDemoDoneClient"
|
||||||
|
className="w-4 h-4 text-odoo-primary border-gray-200 rounded focus:ring-odoo-primary"
|
||||||
|
checked={formData.isDemoDone}
|
||||||
|
onChange={e => setFormData({ ...formData, isDemoDone: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor="isDemoDoneClient" className="text-sm font-bold text-gray-700">Demo Activity Completed</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 bg-gray-50 rounded-xl border border-dashed border-gray-300">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<label className="text-[12px] font-black text-gray-500 uppercase tracking-widest flex items-center gap-2">
|
||||||
|
<Paperclip size={14} /> Client Attachments
|
||||||
|
</label>
|
||||||
|
<label className="cursor-pointer bg-emerald-500 text-white px-3 py-1.5 rounded-lg hover:bg-emerald-600 transition-all flex items-center gap-1 text-[11px] font-black shadow-sm">
|
||||||
|
<Plus size={14} /> ADD FILE
|
||||||
|
<input type="file" multiple className="hidden" onChange={handleFileChange} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedFiles.length > 0 ? (
|
||||||
|
<div className="space-y-2 max-h-[200px] overflow-y-auto pr-1">
|
||||||
|
{selectedFiles.map((file, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between bg-white p-2 px-3 rounded-lg border border-gray-200 shadow-sm animate-in fade-in slide-in-from-top-1">
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<div className="w-8 h-8 bg-blue-50 text-blue-500 rounded flex items-center justify-center shrink-0">
|
||||||
|
<Paperclip size={14} />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<a
|
||||||
|
href={getFileUrl(file.url)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-[13px] font-bold text-gray-800 truncate hover:text-odoo-primary hover:underline block"
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</a>
|
||||||
|
<div className="text-[10px] text-gray-400 uppercase font-bold">{(file.size / 1024).toFixed(1)} KB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFile(idx)}
|
||||||
|
className="text-gray-300 hover:text-rose-500 p-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-6 text-gray-400">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-widest">No documents attached</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-hidden h-[500px] flex flex-col p-4 bg-gray-50">
|
||||||
|
<ActivitiesManager initialClientId={client?.id} />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-between items-center">
|
<div className="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-between items-center">
|
||||||
|
|
@ -255,14 +459,17 @@ export default function ClientModal({ isOpen, onClose, onSave, onDelete, client
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
{activeTab === 'details' && (
|
||||||
onClick={handleSubmit}
|
<button
|
||||||
disabled={submitting || deleting}
|
form="client-form"
|
||||||
className={`flex items-center space-x-2 bg-odoo-primary hover:bg-odoo-primary/90 text-white px-8 py-2.5 rounded-xl font-bold shadow-lg shadow-odoo-primary/20 transition-all active:scale-95 disabled:opacity-50`}
|
type="submit"
|
||||||
>
|
disabled={submitting || deleting}
|
||||||
{submitting ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
|
className={`flex items-center space-x-2 bg-odoo-primary hover:bg-odoo-primary/90 text-white px-8 py-2.5 rounded-xl font-bold shadow-lg shadow-odoo-primary/20 transition-all active:scale-95 disabled:opacity-50`}
|
||||||
<span>{client ? 'Update Client' : 'Create Client'}</span>
|
>
|
||||||
</button>
|
{submitting ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
|
||||||
|
<span>{client ? 'Update Client' : 'Create Client'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,426 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import api from '../lib/axios';
|
|
||||||
import { useAuth } from '@/context/AuthContext';
|
|
||||||
import { Calendar, User, Building2, Filter, CheckCircle2, Clock, AlertTriangle, RefreshCw } from 'lucide-react';
|
|
||||||
|
|
||||||
interface Followup {
|
|
||||||
id: string;
|
|
||||||
notes: string;
|
|
||||||
status: string;
|
|
||||||
date: string;
|
|
||||||
createdAt: string;
|
|
||||||
client?: { id: string; name: string };
|
|
||||||
user?: { id: string; name: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilterState {
|
|
||||||
userId: string;
|
|
||||||
clientId: string;
|
|
||||||
dateFrom: string;
|
|
||||||
dateTo: string;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FollowupsManager() {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [followups, setFollowups] = useState<Followup[]>([]);
|
|
||||||
const [users, setUsers] = useState<any[]>([]);
|
|
||||||
const [clients, setClients] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [reassigning, setReassigning] = useState<string | null>(null); // followup id being reassigned
|
|
||||||
const [reassignUserId, setReassignUserId] = useState('');
|
|
||||||
const [filters, setFilters] = useState<FilterState>({
|
|
||||||
userId: '', clientId: '', dateFrom: '', dateTo: '', status: ''
|
|
||||||
});
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
||||||
const [newFollowup, setNewFollowup] = useState({
|
|
||||||
userId: '',
|
|
||||||
clientId: '',
|
|
||||||
notes: '',
|
|
||||||
date: '',
|
|
||||||
time: '10:00'
|
|
||||||
});
|
|
||||||
|
|
||||||
const isAdminOrGM = ['ADMIN', 'GENERAL_MANAGER'].includes(user?.role || '');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchFollowups();
|
|
||||||
if (isAdminOrGM) {
|
|
||||||
api.get('/users').then(r => setUsers(r.data)).catch(() => {});
|
|
||||||
api.get('/clients').then(r => setClients(r.data)).catch(() => {});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchFollowups = async (f: FilterState = filters) => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (f.userId) params.append('userId', f.userId);
|
|
||||||
if (f.clientId) params.append('clientId', f.clientId);
|
|
||||||
if (f.dateFrom) params.append('dateFrom', f.dateFrom);
|
|
||||||
if (f.dateTo) params.append('dateTo', f.dateTo);
|
|
||||||
if (f.status) params.append('status', f.status);
|
|
||||||
const res = await api.get(`/followups?${params.toString()}`);
|
|
||||||
setFollowups(res.data);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFilterChange = (key: keyof FilterState, value: string) => {
|
|
||||||
const updated = { ...filters, [key]: value };
|
|
||||||
setFilters(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApply = () => fetchFollowups(filters);
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
const reset: FilterState = { userId: '', clientId: '', dateFrom: '', dateTo: '', status: '' };
|
|
||||||
setFilters(reset);
|
|
||||||
fetchFollowups(reset);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMarkDone = async (id: string) => {
|
|
||||||
if (!window.confirm('Mark this follow-up as DONE?')) return;
|
|
||||||
try {
|
|
||||||
await api.patch(`/followups/${id}`, { status: 'DONE' });
|
|
||||||
setFollowups(followups.map(f => f.id === id ? { ...f, status: 'DONE' } : f));
|
|
||||||
} catch (e) {
|
|
||||||
alert('Failed to update status.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReassign = async (followupId: string) => {
|
|
||||||
if (!reassignUserId) { alert('Please select a user to reassign to.'); return; }
|
|
||||||
try {
|
|
||||||
await api.patch(`/followups/${followupId}`, { userId: reassignUserId });
|
|
||||||
setReassigning(null);
|
|
||||||
setReassignUserId('');
|
|
||||||
fetchFollowups(filters);
|
|
||||||
} catch (e) {
|
|
||||||
alert('Failed to reassign task.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!newFollowup.clientId || !newFollowup.userId || !newFollowup.date) {
|
|
||||||
alert('Please fill in all required fields.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dateStr = `${newFollowup.date}T${newFollowup.time}:00`;
|
|
||||||
await api.post('/followups', {
|
|
||||||
clientId: newFollowup.clientId,
|
|
||||||
userId: newFollowup.userId,
|
|
||||||
notes: newFollowup.notes,
|
|
||||||
date: new Date(dateStr).toISOString(),
|
|
||||||
status: 'PENDING'
|
|
||||||
});
|
|
||||||
setIsCreateModalOpen(false);
|
|
||||||
setNewFollowup({ userId: '', clientId: '', notes: '', date: '', time: '10:00' });
|
|
||||||
fetchFollowups(filters);
|
|
||||||
alert('Follow-up scheduled successfully!');
|
|
||||||
} catch (e) {
|
|
||||||
alert('Failed to create follow-up.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const groupByDate = (items: Followup[]) => {
|
|
||||||
const map: Record<string, Followup[]> = {};
|
|
||||||
items.forEach(f => {
|
|
||||||
const key = new Date(f.date).toLocaleDateString('en-IN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
|
||||||
if (!map[key]) map[key] = [];
|
|
||||||
map[key].push(f);
|
|
||||||
});
|
|
||||||
return Object.entries(map);
|
|
||||||
};
|
|
||||||
|
|
||||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const stats = {
|
|
||||||
total: followups.length,
|
|
||||||
pending: followups.filter(f => f.status === 'PENDING').length,
|
|
||||||
overdue: followups.filter(f => f.status === 'PENDING' && new Date(f.date) < today).length,
|
|
||||||
done: followups.filter(f => f.status === 'DONE').length,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white shadow-xl rounded-2xl overflow-hidden border border-gray-100">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-bold text-gray-800">Follow-up Manager</h3>
|
|
||||||
<p className="text-sm text-gray-500">Track and manage all scheduled follow-ups</p>
|
|
||||||
</div>
|
|
||||||
{isAdminOrGM && (
|
|
||||||
<button
|
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
|
||||||
className="bg-odoo-primary text-white px-4 py-2 rounded-xl text-sm font-black hover:shadow-lg transition-all flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span>📅</span> Schedule New
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Row */}
|
|
||||||
<div className="grid grid-cols-4 divide-x divide-gray-100 border-b border-gray-100">
|
|
||||||
{[
|
|
||||||
{ label: 'Total', value: stats.total, icon: <Filter size={14}/>, color: 'text-gray-600', bg: 'bg-gray-50' },
|
|
||||||
{ label: 'Pending', value: stats.pending, icon: <Clock size={14}/>, color: 'text-amber-600', bg: 'bg-amber-50' },
|
|
||||||
{ label: 'Overdue', value: stats.overdue, icon: <AlertTriangle size={14}/>, color: 'text-red-600', bg: 'bg-red-50' },
|
|
||||||
{ label: 'Done', value: stats.done, icon: <CheckCircle2 size={14}/>, color: 'text-emerald-600', bg: 'bg-emerald-50' },
|
|
||||||
].map(s => (
|
|
||||||
<div key={s.label} className={`${s.bg} px-6 py-4 flex items-center gap-3`}>
|
|
||||||
<div className={`${s.color}`}>{s.icon}</div>
|
|
||||||
<div>
|
|
||||||
<div className={`text-2xl font-black ${s.color}`}>{s.value}</div>
|
|
||||||
<div className="text-xs text-gray-500 font-semibold">{s.label}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="px-6 py-4 bg-gray-50/50 border-b border-gray-100">
|
|
||||||
<div className="flex flex-wrap gap-3 items-end">
|
|
||||||
{isAdminOrGM && (
|
|
||||||
<>
|
|
||||||
<div className="flex-1 min-w-[160px]">
|
|
||||||
<label className="block text-xs font-semibold text-gray-500 mb-1"><User size={10} className="inline mr-1"/>User</label>
|
|
||||||
<select
|
|
||||||
value={filters.userId}
|
|
||||||
onChange={e => handleFilterChange('userId', e.target.value)}
|
|
||||||
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white"
|
|
||||||
>
|
|
||||||
<option value="">All Users</option>
|
|
||||||
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-[160px]">
|
|
||||||
<label className="block text-xs font-semibold text-gray-500 mb-1"><Building2 size={10} className="inline mr-1"/>Client</label>
|
|
||||||
<select
|
|
||||||
value={filters.clientId}
|
|
||||||
onChange={e => handleFilterChange('clientId', e.target.value)}
|
|
||||||
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white"
|
|
||||||
>
|
|
||||||
<option value="">All Clients</option>
|
|
||||||
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="min-w-[140px]">
|
|
||||||
<label className="block text-xs font-semibold text-gray-500 mb-1"><Calendar size={10} className="inline mr-1"/>From</label>
|
|
||||||
<input type="date" value={filters.dateFrom} onChange={e => handleFilterChange('dateFrom', e.target.value)}
|
|
||||||
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-[140px]">
|
|
||||||
<label className="block text-xs font-semibold text-gray-500 mb-1"><Calendar size={10} className="inline mr-1"/>To</label>
|
|
||||||
<input type="date" value={filters.dateTo} onChange={e => handleFilterChange('dateTo', e.target.value)}
|
|
||||||
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-[120px]">
|
|
||||||
<label className="block text-xs font-semibold text-gray-500 mb-1">Status</label>
|
|
||||||
<select value={filters.status} onChange={e => handleFilterChange('status', e.target.value)}
|
|
||||||
className="w-full p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white">
|
|
||||||
<option value="">All</option>
|
|
||||||
<option value="PENDING">Pending</option>
|
|
||||||
<option value="DONE">Done</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button onClick={handleApply} className="bg-odoo-primary text-white px-4 py-2 rounded-lg text-sm font-bold hover:bg-odoo-primary/90 transition-all">
|
|
||||||
Apply
|
|
||||||
</button>
|
|
||||||
<button onClick={handleReset} className="border border-gray-200 text-gray-600 px-3 py-2 rounded-lg text-sm hover:bg-gray-100 transition-all">
|
|
||||||
<RefreshCw size={14}/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline View */}
|
|
||||||
<div className="overflow-y-auto max-h-[600px] p-6 space-y-8">
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-center py-12 text-gray-400">Loading follow-ups...</div>
|
|
||||||
) : followups.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-4xl mb-3">📭</p>
|
|
||||||
<p className="text-gray-500 font-semibold">No follow-ups match these filters.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
groupByDate(followups).map(([dateLabel, items]) => {
|
|
||||||
const dateObj = new Date(items[0].date);
|
|
||||||
dateObj.setHours(0,0,0,0);
|
|
||||||
const isToday = dateObj.getTime() === today.getTime();
|
|
||||||
const isPast = dateObj < today;
|
|
||||||
return (
|
|
||||||
<div key={dateLabel}>
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className={`text-xs font-black px-3 py-1 rounded-full uppercase tracking-wider ${isToday ? 'bg-odoo-primary text-white' : isPast ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'}`}>
|
|
||||||
{isToday ? '📅 Today' : isPast ? `⚠️ ${dateLabel}` : dateLabel}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 h-px bg-gray-100"/>
|
|
||||||
<span className="text-xs text-gray-400 font-semibold">{items.length} task{items.length !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{items.map(f => (
|
|
||||||
<div key={f.id} className={`flex items-start gap-4 p-4 rounded-xl border transition-all ${f.status === 'DONE' ? 'bg-gray-50 border-gray-100 opacity-70' : isPast && f.status === 'PENDING' ? 'bg-red-50 border-red-200' : 'bg-white border-gray-200 hover:shadow-md'}`}>
|
|
||||||
<div className={`mt-1 w-3 h-3 rounded-full flex-shrink-0 ${f.status === 'DONE' ? 'bg-emerald-500' : isPast ? 'bg-red-500' : 'bg-amber-400'}`}/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{f.client && <span className="text-sm font-bold text-odoo-primary">{f.client.name}</span>}
|
|
||||||
{f.user && isAdminOrGM && <span className="text-xs text-gray-500 font-semibold">• Assigned to {f.user.name}</span>}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-700 mt-1 leading-relaxed">{f.notes}</p>
|
|
||||||
<p className="text-xs text-gray-400 mt-2 font-semibold">
|
|
||||||
🕐 {new Date(f.date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex-shrink-0 flex flex-col items-end gap-2">
|
|
||||||
{f.status === 'DONE' ? (
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs font-bold px-3 py-1 bg-emerald-100 text-emerald-700 rounded-full">
|
|
||||||
<CheckCircle2 size={11}/> Done
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => handleMarkDone(f.id)}
|
|
||||||
className="text-xs font-bold px-3 py-1.5 bg-odoo-primary text-white rounded-lg hover:bg-odoo-primary/90 transition-all active:scale-95"
|
|
||||||
>
|
|
||||||
Mark Done
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Reassign — Admin/GM only */}
|
|
||||||
{isAdminOrGM && f.status !== 'DONE' && (
|
|
||||||
reassigning === f.id ? (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<select
|
|
||||||
value={reassignUserId}
|
|
||||||
onChange={e => setReassignUserId(e.target.value)}
|
|
||||||
className="text-xs p-1.5 border border-gray-300 rounded-lg outline-none focus:ring-2 focus:ring-odoo-primary bg-white"
|
|
||||||
autoFocus
|
|
||||||
>
|
|
||||||
<option value="">Pick user...</option>
|
|
||||||
{users.filter(u => u.id !== f.user?.id).map(u => (
|
|
||||||
<option key={u.id} value={u.id}>{u.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={() => handleReassign(f.id)}
|
|
||||||
className="text-xs font-bold px-2 py-1.5 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-all"
|
|
||||||
>Go</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setReassigning(null); setReassignUserId(''); }}
|
|
||||||
className="text-xs px-2 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-100 transition-all"
|
|
||||||
>✕</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => { setReassigning(f.id); setReassignUserId(''); }}
|
|
||||||
className="text-xs font-semibold px-3 py-1 border border-amber-300 text-amber-700 bg-amber-50 rounded-lg hover:bg-amber-100 transition-all"
|
|
||||||
>
|
|
||||||
↩ Reassign
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create Follow-up Modal */}
|
|
||||||
{isCreateModalOpen && (
|
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[999] flex items-center justify-center p-4">
|
|
||||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
|
|
||||||
<div className="bg-odoo-primary px-6 py-4 text-white flex justify-between items-center">
|
|
||||||
<h3 className="text-lg font-bold">Schedule New Follow-up</h3>
|
|
||||||
<button onClick={() => setIsCreateModalOpen(false)} className="hover:bg-white/20 p-1 rounded-lg">✕</button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleCreateSubmit} className="p-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase tracking-wider">Client *</label>
|
|
||||||
<select
|
|
||||||
required
|
|
||||||
value={newFollowup.clientId}
|
|
||||||
onChange={e => setNewFollowup({ ...newFollowup, clientId: e.target.value })}
|
|
||||||
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary"
|
|
||||||
>
|
|
||||||
<option value="">Select Client...</option>
|
|
||||||
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase tracking-wider">Assign To *</label>
|
|
||||||
<select
|
|
||||||
required
|
|
||||||
value={newFollowup.userId}
|
|
||||||
onChange={e => setNewFollowup({ ...newFollowup, userId: e.target.value })}
|
|
||||||
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary"
|
|
||||||
>
|
|
||||||
<option value="">Assign User...</option>
|
|
||||||
{users.map(u => <option key={u.id} value={u.id}>{u.name} ({u.role})</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase tracking-wider">Date *</label>
|
|
||||||
<input
|
|
||||||
type="date" required
|
|
||||||
value={newFollowup.date}
|
|
||||||
onChange={e => setNewFollowup({ ...newFollowup, date: e.target.value })}
|
|
||||||
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase tracking-wider">Time *</label>
|
|
||||||
<input
|
|
||||||
type="time" required
|
|
||||||
value={newFollowup.time}
|
|
||||||
onChange={e => setNewFollowup({ ...newFollowup, time: e.target.value })}
|
|
||||||
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase tracking-wider">Notes / Task *</label>
|
|
||||||
<textarea
|
|
||||||
required
|
|
||||||
value={newFollowup.notes}
|
|
||||||
onChange={e => setNewFollowup({ ...newFollowup, notes: e.target.value })}
|
|
||||||
placeholder="What needs to be done?"
|
|
||||||
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary h-24 resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsCreateModalOpen(false)}
|
|
||||||
className="flex-1 px-4 py-3 border border-gray-200 rounded-xl font-bold text-gray-600 hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="flex-[2] bg-odoo-primary text-white px-4 py-3 rounded-xl font-black shadow-lg hover:shadow-odoo-primary/20 hover:scale-[1.02] transition-all"
|
|
||||||
>
|
|
||||||
Schedule Task
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { X, Pencil, Plus, TrendingUp, Star, MoreHorizontal, Clock, CheckCircle2, AlertCircle } from 'lucide-react';
|
import { X, Pencil, Plus, TrendingUp, Star, MoreHorizontal, Clock, CheckCircle2, AlertCircle, Paperclip, FileText, Download } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
|
|
@ -22,14 +22,26 @@ import {
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import api from '@/lib/axios';
|
import api from '@/lib/axios';
|
||||||
|
import ClientModal from './ClientModal';
|
||||||
|
import ActivitiesManager from './ActivitiesManager';
|
||||||
|
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
interface Opportunity {
|
interface Opportunity {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
value: number;
|
value: number;
|
||||||
stage: 'LEAD' | 'QUALIFIED' | 'POTENTIAL' | 'DEMO' | 'WON' | 'LOST';
|
stage: 'LEAD' | 'QUALIFIED' | 'POTENTIAL' | 'WON' | 'LOST';
|
||||||
client: { name: string; id: string };
|
isDemoDone?: boolean;
|
||||||
|
client: {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
companyName?: string;
|
||||||
|
contactName?: string;
|
||||||
|
closingProbability?: number;
|
||||||
|
expectedClosingTimeframe?: string;
|
||||||
|
files?: any[];
|
||||||
|
};
|
||||||
user: { name: string };
|
user: { name: string };
|
||||||
priority?: 'Low' | 'Normal' | 'High';
|
priority?: 'Low' | 'Normal' | 'High';
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
|
@ -80,13 +92,6 @@ const STAGE_CONFIG: Record<string, { title: string; bg: string; text: string; ac
|
||||||
accent: 'bg-amber-500',
|
accent: 'bg-amber-500',
|
||||||
bar: 'bg-amber-400'
|
bar: 'bg-amber-400'
|
||||||
},
|
},
|
||||||
'DEMO': {
|
|
||||||
title: 'Demo',
|
|
||||||
bg: 'bg-[#f8f9fa]',
|
|
||||||
text: 'text-gray-700',
|
|
||||||
accent: 'bg-blue-500',
|
|
||||||
bar: 'bg-blue-400'
|
|
||||||
},
|
|
||||||
'WON': {
|
'WON': {
|
||||||
title: 'Won',
|
title: 'Won',
|
||||||
bg: 'bg-[#e7f3f2]',
|
bg: 'bg-[#e7f3f2]',
|
||||||
|
|
@ -162,12 +167,32 @@ const SortableItem = ({ opportunity, onEdit }: { opportunity: Opportunity; onEdi
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
{renderStars(opportunity.priority)}
|
{renderStars(opportunity.priority)}
|
||||||
<StatusDot status={opportunity.status || 'on_track'} />
|
<StatusDot status={opportunity.status || 'on_track'} />
|
||||||
|
<button
|
||||||
|
className="p-1 hover:bg-gray-100 rounded text-odoo-primary transition-colors"
|
||||||
|
title="Schedule Activity"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit(opportunity); // This will open modal, we can tell it to focus activities
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clock size={12} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="text-[11px] text-gray-500 font-medium truncate max-w-[80px]">
|
<span className="text-[11px] text-gray-500 font-medium truncate max-w-[80px]" title={opportunity.client?.companyName || opportunity.client?.name}>
|
||||||
{opportunity.client?.name}
|
{opportunity.client?.companyName || opportunity.client?.name}
|
||||||
</span>
|
</span>
|
||||||
|
{opportunity.client?.contactName && (
|
||||||
|
<span className="text-[10px] text-gray-400 italic truncate max-w-[60px]">
|
||||||
|
({opportunity.client.contactName})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{opportunity.isDemoDone && (
|
||||||
|
<div className="flex items-center gap-0.5 px-1 bg-blue-100 text-blue-700 rounded text-[9px] font-bold">
|
||||||
|
<CheckCircle2 size={8} /> DEMO
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="h-5 w-5 rounded-full bg-odoo-accent/20 flex items-center justify-center text-[9px] font-bold text-odoo-primary border border-odoo-primary/10">
|
<div className="h-5 w-5 rounded-full bg-odoo-accent/20 flex items-center justify-center text-[9px] font-bold text-odoo-primary border border-odoo-primary/10">
|
||||||
{opportunity.user?.name?.charAt(0).toUpperCase()}
|
{opportunity.user?.name?.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -253,10 +278,36 @@ export default function OpportunityBoard() {
|
||||||
const [items, setItems] = useState<Opportunity[]>([]);
|
const [items, setItems] = useState<Opportunity[]>([]);
|
||||||
const [clients, setClients] = useState<any[]>([]);
|
const [clients, setClients] = useState<any[]>([]);
|
||||||
const [assignees, setAssignees] = useState<any[]>([]);
|
const [assignees, setAssignees] = useState<any[]>([]);
|
||||||
|
const [products, setProducts] = useState<any[]>([]);
|
||||||
const [loadingAssignees, setLoadingAssignees] = useState(false);
|
const [loadingAssignees, setLoadingAssignees] = useState(false);
|
||||||
|
|
||||||
|
const getFileUrl = (url: string) => {
|
||||||
|
if (!url) return '#';
|
||||||
|
if (url.includes('fake-storage.com')) {
|
||||||
|
alert('This file was attached using the old mock system and is no longer available. Please delete it and re-upload the document.');
|
||||||
|
return '#';
|
||||||
|
}
|
||||||
|
if (url.startsWith('http')) return url;
|
||||||
|
|
||||||
|
let base = api.defaults.baseURL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
if (!base.startsWith('http')) {
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3001';
|
||||||
|
base = origin.replace(':3001', ':3000') + (base.startsWith('/') ? base : '/' + base);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
|
||||||
|
const normalizedUrl = url.startsWith('/') ? url : `/${url}`;
|
||||||
|
return `${normalizedBase}${normalizedUrl}`;
|
||||||
|
};
|
||||||
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [isClientModalOpen, setIsClientModalOpen] = useState(false);
|
||||||
|
const [isProductModalOpen, setIsProductModalOpen] = useState(false);
|
||||||
|
const [newProductData, setNewProductData] = useState({ name: '', price: '', description: '' });
|
||||||
|
const [activeModalTab, setActiveModalTab] = useState<'details' | 'activities'>('details');
|
||||||
const [newItemData, setNewItemData] = useState({
|
const [newItemData, setNewItemData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
value: '',
|
value: '',
|
||||||
|
|
@ -276,7 +327,8 @@ export default function OpportunityBoard() {
|
||||||
negotiationRemarks: '',
|
negotiationRemarks: '',
|
||||||
creatorId: '',
|
creatorId: '',
|
||||||
demoOwnerId: '',
|
demoOwnerId: '',
|
||||||
closingOwnerId: ''
|
closingOwnerId: '',
|
||||||
|
isDemoDone: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
|
|
@ -288,6 +340,7 @@ export default function OpportunityBoard() {
|
||||||
fetchOpportunities();
|
fetchOpportunities();
|
||||||
fetchClients();
|
fetchClients();
|
||||||
fetchAssignees();
|
fetchAssignees();
|
||||||
|
fetchProducts();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchOpportunities = async () => {
|
const fetchOpportunities = async () => {
|
||||||
|
|
@ -311,14 +364,17 @@ export default function OpportunityBoard() {
|
||||||
} catch (err) { console.error(err) }
|
} catch (err) { console.error(err) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchProducts = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/products');
|
||||||
|
setProducts(data);
|
||||||
|
} catch (err) { console.error(err) }
|
||||||
|
}
|
||||||
|
|
||||||
const fetchAssignees = async () => {
|
const fetchAssignees = async () => {
|
||||||
setLoadingAssignees(true);
|
setLoadingAssignees(true);
|
||||||
try {
|
try {
|
||||||
let endpoint = '/users';
|
const { data } = await api.get('/users');
|
||||||
if (['MANAGER', 'TEAM_LEADER'].includes(user?.role || '')) {
|
|
||||||
endpoint = '/users/me/subordinates';
|
|
||||||
}
|
|
||||||
const { data } = await api.get(endpoint);
|
|
||||||
setAssignees(data);
|
setAssignees(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch assignees', err);
|
console.error('Failed to fetch assignees', err);
|
||||||
|
|
@ -348,11 +404,29 @@ export default function OpportunityBoard() {
|
||||||
negotiationRemarks: '',
|
negotiationRemarks: '',
|
||||||
creatorId: user?.id || '',
|
creatorId: user?.id || '',
|
||||||
demoOwnerId: '',
|
demoOwnerId: '',
|
||||||
closingOwnerId: ''
|
closingOwnerId: '',
|
||||||
|
isDemoDone: false
|
||||||
});
|
});
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveProduct = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const res = await api.post('/products', {
|
||||||
|
name: newProductData.name,
|
||||||
|
price: parseFloat(newProductData.price) || 0,
|
||||||
|
description: newProductData.description
|
||||||
|
});
|
||||||
|
setProducts([...products, res.data]);
|
||||||
|
setNewItemData(prev => ({ ...prev, title: res.data.name, value: prev.value || String(res.data.price) }));
|
||||||
|
setIsProductModalOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save product', err);
|
||||||
|
alert('Failed to save product');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleEditClick = (item: Opportunity) => {
|
const handleEditClick = (item: Opportunity) => {
|
||||||
setEditingId(item.id);
|
setEditingId(item.id);
|
||||||
setNewItemData({
|
setNewItemData({
|
||||||
|
|
@ -374,8 +448,10 @@ export default function OpportunityBoard() {
|
||||||
negotiationRemarks: item.negotiationRemarks || '',
|
negotiationRemarks: item.negotiationRemarks || '',
|
||||||
creatorId: (item as any).creatorId || '',
|
creatorId: (item as any).creatorId || '',
|
||||||
demoOwnerId: (item as any).demoOwnerId || '',
|
demoOwnerId: (item as any).demoOwnerId || '',
|
||||||
closingOwnerId: (item as any).closingOwnerId || ''
|
closingOwnerId: (item as any).closingOwnerId || '',
|
||||||
|
isDemoDone: !!item.isDemoDone
|
||||||
});
|
});
|
||||||
|
setActiveModalTab('details');
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -404,6 +480,19 @@ export default function OpportunityBoard() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveClient = async (clientData: any) => {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/clients', clientData);
|
||||||
|
const created = response.data;
|
||||||
|
setClients(prev => [created, ...prev]);
|
||||||
|
setNewItemData(prev => ({ ...prev, clientId: created.id }));
|
||||||
|
setIsClientModalOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save client", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragStart = (event: any) => {
|
const handleDragStart = (event: any) => {
|
||||||
setActiveId(event.active.id);
|
setActiveId(event.active.id);
|
||||||
};
|
};
|
||||||
|
|
@ -426,7 +515,7 @@ export default function OpportunityBoard() {
|
||||||
newStage = overItem.stage;
|
newStage = overItem.stage;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stages = ['LEAD', 'QUALIFIED', 'POTENTIAL', 'DEMO', 'WON'];
|
const stages = ['LEAD', 'QUALIFIED', 'POTENTIAL', 'WON'];
|
||||||
if (activeItem.stage !== newStage && stages.includes(newStage as any)) {
|
if (activeItem.stage !== newStage && stages.includes(newStage as any)) {
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
|
|
@ -437,7 +526,7 @@ export default function OpportunityBoard() {
|
||||||
try {
|
try {
|
||||||
await api.patch(`/opportunities/${activeId}`, { stage: newStage });
|
await api.patch(`/opportunities/${activeId}`, { stage: newStage });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (newStage === 'DEMO' || newStage === 'WON') {
|
if (newStage === 'WON') {
|
||||||
// Open modal and explicitly set the target stage
|
// Open modal and explicitly set the target stage
|
||||||
setEditingId(activeItem.id);
|
setEditingId(activeItem.id);
|
||||||
setNewItemData({
|
setNewItemData({
|
||||||
|
|
@ -459,7 +548,8 @@ export default function OpportunityBoard() {
|
||||||
negotiationRemarks: activeItem.negotiationRemarks || '',
|
negotiationRemarks: activeItem.negotiationRemarks || '',
|
||||||
creatorId: (activeItem as any).creatorId || '',
|
creatorId: (activeItem as any).creatorId || '',
|
||||||
demoOwnerId: (activeItem as any).demoOwnerId || '',
|
demoOwnerId: (activeItem as any).demoOwnerId || '',
|
||||||
closingOwnerId: (activeItem as any).closingOwnerId || ''
|
closingOwnerId: (activeItem as any).closingOwnerId || '',
|
||||||
|
isDemoDone: !!activeItem.isDemoDone
|
||||||
});
|
});
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -471,7 +561,7 @@ export default function OpportunityBoard() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stages = ['LEAD', 'QUALIFIED', 'POTENTIAL', 'DEMO', 'WON'];
|
const stages = ['LEAD', 'QUALIFIED', 'POTENTIAL', 'WON'];
|
||||||
const getColumnTotal = (stage: string) => items.filter(i => i.stage === stage).reduce((sum, i) => sum + i.value, 0);
|
const getColumnTotal = (stage: string) => items.filter(i => i.stage === stage).reduce((sum, i) => sum + i.value, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -537,7 +627,12 @@ export default function OpportunityBoard() {
|
||||||
|
|
||||||
{/* Modal - Refined Odoo Style */}
|
{/* Modal - Refined Odoo Style */}
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-[2px] z-50 flex items-center justify-center p-4">
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-[2px] z-50 flex items-center justify-center p-4"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setIsModalOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="bg-white rounded shadow-2xl w-full max-w-2xl overflow-hidden animate-in fade-in zoom-in duration-200">
|
<div className="bg-white rounded shadow-2xl w-full max-w-2xl overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200 flex justify-between items-center">
|
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200 flex justify-between items-center">
|
||||||
<h3 className="font-bold text-[18px] text-gray-800">{editingId ? 'Edit Deal' : 'New Deal'}</h3>
|
<h3 className="font-bold text-[18px] text-gray-800">{editingId ? 'Edit Deal' : 'New Deal'}</h3>
|
||||||
|
|
@ -545,18 +640,71 @@ export default function OpportunityBoard() {
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleCreateSubmit} className="flex flex-col max-h-[90vh]">
|
|
||||||
<div className="flex-1 overflow-y-auto p-8 grid grid-cols-2 gap-x-12 gap-y-6">
|
{editingId && (
|
||||||
|
<div className="flex border-b border-gray-200 bg-white">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveModalTab('details')}
|
||||||
|
className={`px-6 py-3 text-sm font-bold transition-all border-b-2 ${activeModalTab === 'details' ? 'border-odoo-primary text-odoo-primary bg-gray-50/50' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||||
|
>
|
||||||
|
DEAL DETAILS
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveModalTab('activities')}
|
||||||
|
className={`px-6 py-3 text-sm font-bold transition-all border-b-2 ${activeModalTab === 'activities' ? 'border-odoo-primary text-odoo-primary bg-gray-50/50' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||||
|
>
|
||||||
|
ACTIVITIES & LOGS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModalTab === 'details' ? (
|
||||||
|
<form onSubmit={handleCreateSubmit} className="flex flex-col max-h-[85vh]">
|
||||||
|
<div className="flex-1 overflow-y-auto p-8 grid grid-cols-2 gap-x-12 gap-y-6">
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="block text-[13px] font-bold text-gray-500 mb-1">Opportunity Title</label>
|
<label className="block text-[13px] font-bold text-gray-500 mb-1">Opportunity Title / Product</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
|
list="product-list"
|
||||||
className="w-full border-b-2 border-gray-200 py-2 text-[18px] font-semibold focus:border-odoo-primary outline-none transition-all placeholder:text-gray-300"
|
className="w-full border-b-2 border-gray-200 py-2 text-[18px] font-semibold focus:border-odoo-primary outline-none transition-all placeholder:text-gray-300"
|
||||||
value={newItemData.title}
|
value={newItemData.title}
|
||||||
onChange={e => setNewItemData({ ...newItemData, title: e.target.value })}
|
onChange={e => {
|
||||||
|
const val = e.target.value;
|
||||||
|
const matchedProduct = products.find(p => p.name.toLowerCase() === val.toLowerCase());
|
||||||
|
setNewItemData({
|
||||||
|
...newItemData,
|
||||||
|
title: val,
|
||||||
|
value: matchedProduct ? String(matchedProduct.price) : newItemData.value
|
||||||
|
});
|
||||||
|
}}
|
||||||
placeholder="e.g. Website Redesign"
|
placeholder="e.g. Website Redesign"
|
||||||
/>
|
/>
|
||||||
|
<datalist id="product-list">
|
||||||
|
{products.map(p => <option key={p.id} value={p.name} />)}
|
||||||
|
</datalist>
|
||||||
|
|
||||||
|
{newItemData.title && !products.some(p => p.name.toLowerCase() === newItemData.title.toLowerCase()) && (
|
||||||
|
<div className="flex items-center gap-2 mt-2 bg-odoo-primary/5 p-2 rounded border border-odoo-primary/20">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="saveProduct"
|
||||||
|
className="w-4 h-4 text-odoo-primary rounded focus:ring-odoo-primary cursor-pointer"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setNewProductData({ name: newItemData.title, price: newItemData.value || '', description: '' });
|
||||||
|
setIsProductModalOpen(true);
|
||||||
|
e.target.checked = false; // Reset checkbox immediately since we use modal state
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor="saveProduct" className="text-[12px] font-bold text-odoo-primary cursor-pointer hover:underline">
|
||||||
|
Save "{newItemData.title}" as a new product in the catalog
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -573,6 +721,16 @@ export default function OpportunityBoard() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[13px] font-bold text-gray-500 mb-1">Expected Closing Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="w-full border-b border-gray-200 py-1 focus:border-odoo-primary outline-none text-[14px]"
|
||||||
|
value={newItemData.expectedCloseDate}
|
||||||
|
onChange={e => setNewItemData({ ...newItemData, expectedCloseDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[13px] font-bold text-gray-500 mb-1">Priority</label>
|
<label className="block text-[13px] font-bold text-gray-500 mb-1">Priority</label>
|
||||||
<div className="flex space-x-4 mt-2">
|
<div className="flex space-x-4 mt-2">
|
||||||
|
|
@ -595,7 +753,16 @@ export default function OpportunityBoard() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[13px] font-bold text-gray-500 mb-1">Client</label>
|
<div className="flex justify-between items-end mb-1">
|
||||||
|
<label className="block text-[13px] font-bold text-gray-500">Client</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsClientModalOpen(true)}
|
||||||
|
className="text-[11px] font-bold text-odoo-primary hover:underline"
|
||||||
|
>
|
||||||
|
+ Create New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<select
|
<select
|
||||||
required
|
required
|
||||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[14px]"
|
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[14px]"
|
||||||
|
|
@ -603,10 +770,60 @@ export default function OpportunityBoard() {
|
||||||
onChange={e => setNewItemData({ ...newItemData, clientId: e.target.value })}
|
onChange={e => setNewItemData({ ...newItemData, clientId: e.target.value })}
|
||||||
>
|
>
|
||||||
<option value="">Select a client...</option>
|
<option value="">Select a client...</option>
|
||||||
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
{clients.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.companyName || c.name} {c.contactName ? `(${c.contactName})` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Client Attachments Preview in Opportunity Modal */}
|
||||||
|
{newItemData.clientId && (
|
||||||
|
<div className="col-span-2 bg-gray-50/80 rounded-xl p-4 border border-gray-100">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h5 className="text-[11px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5">
|
||||||
|
<Paperclip size={12} /> Client Documents
|
||||||
|
</h5>
|
||||||
|
{(() => {
|
||||||
|
const client = clients.find(c => c.id === newItemData.clientId);
|
||||||
|
const count = client?.files?.length || 0;
|
||||||
|
return <span className="text-[10px] font-bold bg-white text-odoo-primary px-2 py-0.5 rounded-full border border-odoo-primary/10">{count} Files</span>;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{(() => {
|
||||||
|
const client = clients.find(c => c.id === newItemData.clientId);
|
||||||
|
return client?.files && client.files.length > 0 ? (
|
||||||
|
client.files.slice(0, 4).map((file: any, idx: number) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between p-2 bg-white rounded-lg border border-gray-100 shadow-sm hover:border-odoo-primary/30 transition-all group">
|
||||||
|
<div className="flex items-center gap-2 overflow-hidden">
|
||||||
|
<FileText size={12} className="text-gray-400 group-hover:text-odoo-primary shrink-0" />
|
||||||
|
<span className="text-[12px] font-bold text-gray-700 truncate max-w-[100px]">{file.name}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); window.open(getFileUrl(file.url), '_blank'); }}
|
||||||
|
className="p-1 hover:bg-odoo-primary/10 rounded text-gray-400 hover:text-odoo-primary transition-all"
|
||||||
|
>
|
||||||
|
<Download size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="col-span-2 text-[11px] text-gray-400 italic">No attachments for this client.</p>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{(() => {
|
||||||
|
const client = clients.find(c => c.id === newItemData.clientId);
|
||||||
|
if (client?.files && client.files.length > 4) {
|
||||||
|
return <p className="col-span-2 text-[10px] text-center text-odoo-primary font-bold mt-1">+ {client.files.length - 4} more documents in client profile</p>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[13px] font-bold text-gray-500 mb-1">Stage</label>
|
<label className="block text-[13px] font-bold text-gray-500 mb-1">Stage</label>
|
||||||
<select
|
<select
|
||||||
|
|
@ -618,57 +835,66 @@ export default function OpportunityBoard() {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{['ADMIN', 'GENERAL_MANAGER', 'MANAGER', 'TEAM_LEADER'].includes(user?.role || '') && (
|
<div className="flex items-center gap-2 pt-6">
|
||||||
<div className="col-span-2 grid grid-cols-2 gap-4 border-t border-gray-100 pt-4">
|
<input
|
||||||
<div>
|
type="checkbox"
|
||||||
<label className="block text-[13px] font-bold text-gray-500 mb-1">Lead Creator</label>
|
id="isDemoDone"
|
||||||
<select
|
className="w-4 h-4 text-odoo-primary border-gray-300 rounded focus:ring-odoo-primary"
|
||||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[14px]"
|
checked={newItemData.isDemoDone}
|
||||||
value={newItemData.creatorId}
|
onChange={e => setNewItemData({ ...newItemData, isDemoDone: e.target.checked })}
|
||||||
onChange={e => setNewItemData({ ...newItemData, creatorId: e.target.value })}
|
/>
|
||||||
>
|
<label htmlFor="isDemoDone" className="text-[14px] font-bold text-gray-700">Demo Completed?</label>
|
||||||
<option value="">Select creator...</option>
|
</div>
|
||||||
{assignees.map(u => (
|
|
||||||
<option key={u.id} value={u.id}>{u.name} ({u.role})</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-[13px] font-bold text-gray-500 mb-1">Primary Owner (Assigned)</label>
|
|
||||||
<select
|
|
||||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[14px]"
|
|
||||||
value={newItemData.assignedTo}
|
|
||||||
onChange={e => setNewItemData({ ...newItemData, assignedTo: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="">Select teammate...</option>
|
|
||||||
{assignees.map(u => (
|
|
||||||
<option key={u.id} value={u.id}>{u.name} ({u.role})</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* DEMO STAGE FIELDS */}
|
<div className="col-span-2 grid grid-cols-2 gap-4 border-t border-gray-100 pt-4">
|
||||||
{(newItemData.stage === 'DEMO' || newItemData.stage === 'WON') && (
|
<div>
|
||||||
|
<label className="block text-[13px] font-bold text-gray-500 mb-1">Lead Creator</label>
|
||||||
|
<select
|
||||||
|
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[14px]"
|
||||||
|
value={newItemData.creatorId}
|
||||||
|
onChange={e => setNewItemData({ ...newItemData, creatorId: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Select creator...</option>
|
||||||
|
<option value={user?.id}>Myself ({user?.name})</option>
|
||||||
|
{assignees.filter(u => u.id !== user?.id).map(u => (
|
||||||
|
<option key={u.id} value={u.id}>{u.name} ({u.role})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[13px] font-bold text-gray-500 mb-1">Primary Owner (Assigned)</label>
|
||||||
|
<select
|
||||||
|
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[14px]"
|
||||||
|
value={newItemData.assignedTo}
|
||||||
|
onChange={e => setNewItemData({ ...newItemData, assignedTo: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Select teammate...</option>
|
||||||
|
<option value={user?.id}>Myself ({user?.name})</option>
|
||||||
|
{assignees.filter(u => u.id !== user?.id).map(u => (
|
||||||
|
<option key={u.id} value={u.id}>{u.name} ({u.role})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DEMO ACTIVITY FIELDS */}
|
||||||
|
{newItemData.isDemoDone && (
|
||||||
<div className="col-span-2 grid grid-cols-2 gap-4 p-4 bg-blue-50/50 rounded border border-blue-100">
|
<div className="col-span-2 grid grid-cols-2 gap-4 p-4 bg-blue-50/50 rounded border border-blue-100">
|
||||||
<div className="col-span-2 font-bold text-blue-700 text-[13px] mb-2 border-b border-blue-100 pb-1">
|
<div className="col-span-2 font-bold text-blue-700 text-[13px] mb-2 border-b border-blue-100 pb-1">
|
||||||
DEMO STAGE INFORMATION (MANDATORY)
|
DEMO ACTIVITY DETAILS
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Demo Person Name *</label>
|
<label className="block text-[12px] font-bold text-gray-500 mb-1">Demo Person Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
|
||||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
||||||
value={newItemData.demoPersonName}
|
value={newItemData.demoPersonName}
|
||||||
onChange={e => setNewItemData({ ...newItemData, demoPersonName: e.target.value })}
|
onChange={e => setNewItemData({ ...newItemData, demoPersonName: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Demo Owner (Select Staff) *</label>
|
<label className="block text-[12px] font-bold text-gray-500 mb-1">Demo Owner (Select Staff)</label>
|
||||||
<select
|
<select
|
||||||
required
|
|
||||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
||||||
value={newItemData.demoOwnerId}
|
value={newItemData.demoOwnerId}
|
||||||
onChange={e => setNewItemData({ ...newItemData, demoOwnerId: e.target.value })}
|
onChange={e => setNewItemData({ ...newItemData, demoOwnerId: e.target.value })}
|
||||||
|
|
@ -680,39 +906,26 @@ export default function OpportunityBoard() {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Contact Details *</label>
|
<label className="block text-[12px] font-bold text-gray-500 mb-1">Contact Details</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
|
||||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
||||||
value={newItemData.demoContactDetails}
|
value={newItemData.demoContactDetails}
|
||||||
onChange={e => setNewItemData({ ...newItemData, demoContactDetails: e.target.value })}
|
onChange={e => setNewItemData({ ...newItemData, demoContactDetails: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Expected Closing Date *</label>
|
<label className="block text-[12px] font-bold text-gray-500 mb-1">Competitor Mention</label>
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
|
||||||
value={newItemData.expectedCloseDate}
|
|
||||||
onChange={e => setNewItemData({ ...newItemData, expectedCloseDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Competitor Mention *</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
|
||||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
|
||||||
value={newItemData.competitorMention}
|
value={newItemData.competitorMention}
|
||||||
onChange={e => setNewItemData({ ...newItemData, competitorMention: e.target.value })}
|
onChange={e => setNewItemData({ ...newItemData, competitorMention: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="block text-[12px] font-bold text-gray-500 mb-1">Key Queries & Objections *</label>
|
<label className="block text-[12px] font-bold text-gray-500 mb-1">Key Queries & Objections</label>
|
||||||
<textarea
|
<textarea
|
||||||
required
|
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px] resize-none"
|
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px] resize-none"
|
||||||
placeholder="Key queries, objections raised..."
|
placeholder="Key queries, objections raised..."
|
||||||
|
|
@ -787,26 +1000,64 @@ export default function OpportunityBoard() {
|
||||||
onChange={e => setNewItemData({ ...newItemData, negotiationRemarks: e.target.value })}
|
onChange={e => setNewItemData({ ...newItemData, negotiationRemarks: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
|
<div className="px-8 py-4 bg-gray-50 border-t border-gray-200 flex space-x-3 shrink-0">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-odoo-primary hover:bg-odoo-primary/90 text-white px-6 py-2 rounded font-bold text-[14px] shadow-md transition-all"
|
||||||
|
>
|
||||||
|
{editingId ? 'SAVE' : 'CREATE'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 px-6 py-2 rounded font-bold text-[14px] transition-all"
|
||||||
|
>
|
||||||
|
DISCARD
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-hidden h-[600px] flex flex-col p-4 bg-gray-50">
|
||||||
|
<ActivitiesManager initialClientId={newItemData.clientId} initialOpportunityId={editingId || undefined} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="px-8 py-4 bg-gray-50 border-t border-gray-200 flex space-x-3 shrink-0">
|
<ClientModal
|
||||||
<button
|
isOpen={isClientModalOpen}
|
||||||
type="submit"
|
onClose={() => setIsClientModalOpen(false)}
|
||||||
className="bg-odoo-primary hover:bg-odoo-primary/90 text-white px-6 py-2 rounded font-bold text-[14px] shadow-md transition-all"
|
onSave={handleSaveClient}
|
||||||
>
|
client={null}
|
||||||
{editingId ? 'SAVE' : 'CREATE'}
|
/>
|
||||||
</button>
|
|
||||||
<button
|
{/* Product Creation Modal */}
|
||||||
type="button"
|
{isProductModalOpen && (
|
||||||
onClick={() => setIsModalOpen(false)}
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4" onClick={(e) => { if (e.target === e.currentTarget) setIsProductModalOpen(false); }}>
|
||||||
className="bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 px-6 py-2 rounded font-bold text-[14px] transition-all"
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-sm overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||||
>
|
<div className="px-6 py-4 bg-odoo-primary flex justify-between items-center text-white">
|
||||||
DISCARD
|
<h3 className="font-bold text-lg">Save New Product</h3>
|
||||||
</button>
|
<button onClick={() => setIsProductModalOpen(false)} className="hover:bg-white/20 p-1 rounded-lg transition-colors"><X size={20} /></button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSaveProduct} className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] font-bold text-gray-500 mb-1">Product Name *</label>
|
||||||
|
<input type="text" required value={newProductData.name} onChange={e => setNewProductData({...newProductData, name: e.target.value})} className="w-full border-b-2 border-gray-200 py-2 focus:border-odoo-primary outline-none text-sm font-semibold" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] font-bold text-gray-500 mb-1">Default Price (₹) *</label>
|
||||||
|
<input type="number" required value={newProductData.price} onChange={e => setNewProductData({...newProductData, price: e.target.value})} className="w-full border-b-2 border-gray-200 py-2 focus:border-odoo-primary outline-none text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] font-bold text-gray-500 mb-1">Description</label>
|
||||||
|
<textarea value={newProductData.description} onChange={e => setNewProductData({...newProductData, description: e.target.value})} className="w-full border-b-2 border-gray-200 py-2 focus:border-odoo-primary outline-none text-sm resize-none" rows={3} placeholder="Optional details..." />
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="w-full bg-emerald-500 text-white font-bold py-3 rounded-lg shadow-md shadow-emerald-500/20 hover:bg-emerald-600 transition-all mt-2">Save Product to Catalog</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,17 @@ interface Quote {
|
||||||
id: string;
|
id: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
status: string;
|
status: string;
|
||||||
pdfUrl?: string; // New field
|
pdfUrl?: string;
|
||||||
enquiry: {
|
opportunity?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
client: {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
enquiry?: {
|
||||||
id: string;
|
id: string;
|
||||||
client: {
|
client: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -19,8 +28,9 @@ interface Quote {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Enquiry {
|
interface Opportunity {
|
||||||
id: string;
|
id: string;
|
||||||
|
title: string;
|
||||||
client: {
|
client: {
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
@ -28,12 +38,12 @@ interface Enquiry {
|
||||||
|
|
||||||
export default function QuoteManager() {
|
export default function QuoteManager() {
|
||||||
const [quotes, setQuotes] = useState<Quote[]>([]);
|
const [quotes, setQuotes] = useState<Quote[]>([]);
|
||||||
const [enquiries, setEnquiries] = useState<Enquiry[]>([]);
|
const [opportunities, setOpportunities] = useState<Opportunity[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
// Form State
|
// Form State
|
||||||
const [selectedEnquiry, setSelectedEnquiry] = useState('');
|
const [selectedOpportunity, setSelectedOpportunity] = useState('');
|
||||||
const [status, setStatus] = useState('DRAFT');
|
const [status, setStatus] = useState('DRAFT');
|
||||||
const [totalAmount, setTotalAmount] = useState<string>('');
|
const [totalAmount, setTotalAmount] = useState<string>('');
|
||||||
const [pdfUrl, setPdfUrl] = useState('');
|
const [pdfUrl, setPdfUrl] = useState('');
|
||||||
|
|
@ -51,12 +61,12 @@ export default function QuoteManager() {
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const [quotesRes, enquiriesRes] = await Promise.all([
|
const [quotesRes, opportunitiesRes] = await Promise.all([
|
||||||
api.get('/quotes'),
|
api.get('/quotes'),
|
||||||
api.get('/enquiries')
|
api.get('/opportunities')
|
||||||
]);
|
]);
|
||||||
setQuotes(quotesRes.data);
|
setQuotes(quotesRes.data);
|
||||||
setEnquiries(enquiriesRes.data);
|
setOpportunities(opportunitiesRes.data);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
@ -97,7 +107,7 @@ export default function QuoteManager() {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
await api.post('/quotes', {
|
await api.post('/quotes', {
|
||||||
enquiryId: selectedEnquiry,
|
opportunityId: selectedOpportunity,
|
||||||
items: [],
|
items: [],
|
||||||
totalAmount: Number(totalAmount),
|
totalAmount: Number(totalAmount),
|
||||||
status,
|
status,
|
||||||
|
|
@ -105,7 +115,7 @@ export default function QuoteManager() {
|
||||||
userId: '52fa316c-77e3-4bfd-80d8-72e96c6b999e'
|
userId: '52fa316c-77e3-4bfd-80d8-72e96c6b999e'
|
||||||
});
|
});
|
||||||
|
|
||||||
setSelectedEnquiry('');
|
setSelectedOpportunity('');
|
||||||
setTotalAmount('');
|
setTotalAmount('');
|
||||||
setStatus('DRAFT');
|
setStatus('DRAFT');
|
||||||
setPdfUrl('');
|
setPdfUrl('');
|
||||||
|
|
@ -175,7 +185,7 @@ export default function QuoteManager() {
|
||||||
<FileText className="w-6 h-6" />
|
<FileText className="w-6 h-6" />
|
||||||
Create New Quote
|
Create New Quote
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-white/80 mt-1">Upload a PDF and assign it to a client enquiry.</p>
|
<p className="text-white/80 mt-1">Upload a PDF and assign it to an opportunity.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
|
|
@ -183,16 +193,16 @@ export default function QuoteManager() {
|
||||||
{/* Left Column: Details */}
|
{/* Left Column: Details */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Select Enquiry</label>
|
<label className="block text-sm font-semibold text-gray-700 mb-2">Select Opportunity</label>
|
||||||
<select
|
<select
|
||||||
value={selectedEnquiry}
|
value={selectedOpportunity}
|
||||||
onChange={e => setSelectedEnquiry(e.target.value)}
|
onChange={e => setSelectedOpportunity(e.target.value)}
|
||||||
className="w-full rounded-lg border-gray-300 shadow-sm focus:border-odoo-primary focus:ring-odoo-primary h-10 px-3 transition-colors text-sm"
|
className="w-full rounded-lg border-gray-300 shadow-sm focus:border-odoo-primary focus:ring-odoo-primary h-10 px-3 transition-colors text-sm"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">-- Choose Client Enquiry --</option>
|
<option value="">-- Choose Opportunity --</option>
|
||||||
{enquiries.map(enq => (
|
{opportunities.map(opp => (
|
||||||
<option key={enq.id} value={enq.id}>{enq.client.name} (ID: {enq.id.substring(0, 8)})</option>
|
<option key={opp.id} value={opp.id}>{opp.client.name} - {opp.title} (ID: {opp.id.substring(0, 8)})</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -282,7 +292,10 @@ export default function QuoteManager() {
|
||||||
{/* Quote Info */}
|
{/* Quote Info */}
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h4 className="text-lg font-bold text-gray-900">{quote.enquiry?.client?.name || 'Unknown Client'}</h4>
|
<div className="flex flex-col">
|
||||||
|
<h4 className="text-lg font-bold text-gray-900">{quote.opportunity?.client?.name || 'Unknown Client'}</h4>
|
||||||
|
<p className="text-xs text-gray-500 font-medium">{quote.opportunity?.title}</p>
|
||||||
|
</div>
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-semibold ${quote.status === 'ACCEPTED' ? 'bg-odoo-secondary/10 text-odoo-secondary' :
|
<span className={`px-2 py-1 rounded-full text-xs font-semibold ${quote.status === 'ACCEPTED' ? 'bg-odoo-secondary/10 text-odoo-secondary' :
|
||||||
quote.status === 'SENT' ? 'bg-odoo-primary/10 text-odoo-primary' :
|
quote.status === 'SENT' ? 'bg-odoo-primary/10 text-odoo-primary' :
|
||||||
'bg-gray-100 text-gray-800'
|
'bg-gray-100 text-gray-800'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue