changes till 12/06/2026

changes till 12/06/2026
main
Manu Krishna 2026-06-12 09:56:05 +05:30
parent 107126b1f4
commit bc417200e0
9 changed files with 3761 additions and 247 deletions

View File

@ -15,6 +15,7 @@ import OpportunityBoard from '@/components/OpportunityBoard';
import TargetManager from '@/components/TargetManager';
import IncentiveManager from '@/components/IncentiveManager';
import ActivitiesManager from '@/components/ActivitiesManager';
import PipelineActivityEngine from '@/components/PipelineActivityEngine';
import FunnelAnalysisPage from '@/components/FunnelAnalysisPage';
import CallLogs from '@/components/CallLogs';
import Settings from '@/components/Settings';
@ -112,7 +113,7 @@ export default function DashboardPage() {
case 'reports':
return <Reports />;
case 'activities':
return <div className="p-6 h-full"><ActivitiesManager /></div>;
return <PipelineActivityEngine />;
case 'call-logs':
return <CallLogs />;
case 'funnel-analysis':

View File

@ -3,11 +3,11 @@
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, Phone, Send, MapPin, Handshake, Mail, MessageCircle, ClipboardCheck, FileSearch } from 'lucide-react';
import { Calendar, User, Building2, Filter, CheckCircle2, Clock, AlertTriangle, RefreshCw, Presentation, FileText, MessageSquare, ListTodo, Phone, Send, MapPin, Handshake, Mail, MessageCircle, ClipboardCheck, FileSearch, TrendingUp } from 'lucide-react';
interface Activity {
id: string;
type: 'CALL' | 'MESSAGE' | 'DEMO_SCHEDULED' | 'DEMO_COMPLETED' | 'QUOTE_REQUEST' | 'QUOTE_SEND' | 'VISIT_SCHEDULED' | 'VISIT_COMPLETED' | 'NEGOTIATION' | 'FOLLOWUP' | 'DEMO' | 'QUOTE';
type: 'CALL' | 'MESSAGE' | 'DEMO_SCHEDULED' | 'DEMO_COMPLETED' | 'QUOTE_REQUEST' | 'QUOTE_SEND' | 'VISIT_SCHEDULED' | 'VISIT_COMPLETED' | 'NEGOTIATION' | 'FOLLOWUP' | 'DEMO' | 'QUOTE' | 'SECOND_DEMO' | 'SECOND_QUOTE' | 'MANAGER_HELP' | 'PROBABILITY_UPDATE' | 'TIMEFRAME_UPDATE';
notes: string;
status: string;
date: string;
@ -35,7 +35,7 @@ interface FilterState {
enquiryId?: string;
}
export default function ActivitiesManager({ initialClientId, initialOpportunityId, initialEnquiryId }: { initialClientId?: string; initialOpportunityId?: string; initialEnquiryId?: string }) {
export default function ActivitiesManager({ initialClientId, initialOpportunityId, initialEnquiryId, initialActivityId }: { initialClientId?: string; initialOpportunityId?: string; initialEnquiryId?: string; initialActivityId?: string }) {
const { user } = useAuth();
const [activities, setActivities] = useState<Activity[]>([]);
const [users, setUsers] = useState<any[]>([]);
@ -48,6 +48,7 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
userId: '', clientId: initialClientId || '', dateFrom: '', dateTo: '', status: '', type: '', opportunityId: initialOpportunityId || '', enquiryId: initialEnquiryId || ''
});
const [feedbackActivity, setFeedbackActivity] = useState<Activity | null>(null);
const [stageChangedMsg, setStageChangedMsg] = useState<string | null>(null);
const [demoFeedback, setDemoFeedback] = useState({
demoPersonName: '',
demoContactDetails: '',
@ -108,10 +109,14 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
if (f.type) params.append('type', f.type);
if (f.opportunityId) params.append('opportunityId', f.opportunityId);
if (f.enquiryId) params.append('enquiryId', f.enquiryId);
const res = await api.get(`/followups?${params.toString()}`);
setActivities(res.data);
const res = await api.get('/followups', { params });
let data = res.data;
if (initialActivityId) {
data = data.filter((a: Activity) => a.id === initialActivityId);
}
setActivities(data);
} catch (e) {
console.error(e);
console.error('Failed to load activities', e);
} finally {
setLoading(false);
}
@ -170,7 +175,7 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
const requiredFields = [
'customerFeedback', 'requirementDetails', 'budget',
'expectedClosingTimeline', 'competitorInfo', 'staffRemarks',
'customerCommitments', 'caCsDetails'
'customerCommitments', 'caCsDetails', 'suggestions'
];
const missing = requiredFields.filter(f => !demoFeedback[f as keyof typeof demoFeedback]);
if (missing.length > 0) {
@ -178,7 +183,7 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
return;
}
} else {
// For other types (like scheduled ones marked done), person name is still helpful
// For scheduled types marked done — person name is still helpful
if (!demoFeedback.demoPersonName || !demoFeedback.demoContactDetails) {
alert('Please provide Person Met and Contact Details.');
return;
@ -192,6 +197,16 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
});
setActivities(activities.map(a => a.id === feedbackActivity.id ? { ...a, status: 'DONE', ...demoFeedback } : a));
setFeedbackActivity(null);
// Determine expected stage change for user feedback
const isVisit = ['VISIT_SCHEDULED', 'VISIT_COMPLETED'].includes(feedbackActivity.type);
const isDemo = ['DEMO', 'DEMO_SCHEDULED', 'DEMO_COMPLETED'].includes(feedbackActivity.type);
if (isVisit) {
setStageChangedMsg('✅ Activity marked done! Pipeline stage auto-advanced to SALES.');
} else if (isDemo) {
setStageChangedMsg('✅ Demo marked done! Pipeline stage auto-advanced to POTENTIAL.');
}
setTimeout(() => setStageChangedMsg(null), 4000);
} catch (e) {
alert('Failed to submit demo feedback.');
}
@ -308,7 +323,14 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
};
return (
<div className="bg-white shadow-xl rounded-2xl overflow-hidden border border-gray-100 h-full flex flex-col">
<div className="bg-white shadow-xl rounded-2xl overflow-hidden border border-gray-100 h-full flex flex-col relative">
{/* Stage Change Toast */}
{stageChangedMsg && (
<div className="absolute top-4 right-4 z-[9999] bg-emerald-500 text-white text-sm font-bold px-5 py-3 rounded-2xl shadow-lg shadow-emerald-500/30 flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
<span>{stageChangedMsg}</span>
<button onClick={() => setStageChangedMsg(null)} className="ml-2 text-white/70 hover:text-white text-lg leading-none">&times;</button>
</div>
)}
{/* Header */}
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0">
<div>
@ -417,108 +439,167 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
</div>
</div>
{/* Timeline View */}
<div className="flex-1 overflow-y-auto p-6 space-y-8 custom-scrollbar">
{/* Table View */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-6">
{loading ? (
<div className="text-center py-12 text-gray-400">Loading activities...</div>
) : activities.length === 0 ? (
<div className="text-center py-12">
) : (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-sm">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-[#e87a2e] text-white text-[11px] uppercase tracking-wider">
<th className="px-4 py-3 font-black border-b border-[#c9621b]">Date & Time</th>
<th className="px-4 py-3 font-black border-b border-[#c9621b]">Activity</th>
<th className="px-4 py-3 font-black border-b border-[#c9621b]">Staff</th>
<th className="px-4 py-3 font-black border-b border-[#c9621b]">Remarks</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200/60 text-xs">
{/* New Activity Quick Add Trigger Row */}
<tr
onClick={() => setIsCreateModalOpen(true)}
className="bg-orange-50/40 hover:bg-orange-50 cursor-pointer border-b-2 border-orange-100 group transition-all"
>
<td colSpan={4} className="px-4 py-3 text-center text-orange-600 font-bold uppercase tracking-widest text-[10px]">
<div className="flex items-center justify-center gap-2 group-hover:scale-105 transition-transform">
<div className="w-5 h-5 rounded-full bg-orange-200 text-orange-600 flex items-center justify-center">+</div>
Click here to add new activity
</div>
</td>
</tr>
{activities.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-12 text-center">
<p className="text-4xl mb-3">📭</p>
<p className="text-gray-500 font-semibold">No activities match these filters.</p>
</div>
</td>
</tr>
) : (
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;
(() => {
const sortedActivities = [...activities].sort((a, b) => {
const dateA = new Date(a.date);
const dateB = new Date(b.date);
const isOverdueA = a.status === 'PENDING' && dateA < today;
const isOverdueB = b.status === 'PENDING' && dateB < today;
if (isOverdueA && !isOverdueB) return -1;
if (!isOverdueA && isOverdueB) return 1;
const isPendingA = a.status === 'PENDING';
const isPendingB = b.status === 'PENDING';
if (isPendingA && !isPendingB) return -1;
if (!isPendingA && isPendingB) return 1;
if (isPendingA && isPendingB) return dateA.getTime() - dateB.getTime();
return dateB.getTime() - dateA.getTime();
});
return sortedActivities.map(a => {
const dateObj = new Date(a.date);
const isOverdue = a.status === 'PENDING' && dateObj < today;
const isPending = a.status === 'PENDING' && !isOverdue;
const isDone = a.status === 'DONE';
let rowColor = "bg-white hover:bg-gray-50";
let textColor = "text-gray-800";
if (isOverdue) {
rowColor = "bg-red-50 hover:bg-red-100/50";
textColor = "text-red-900";
} else if (isPending) {
rowColor = "bg-orange-50 hover:bg-orange-100/50";
textColor = "text-orange-900";
} else if (isDone) {
rowColor = "bg-emerald-50/60 hover:bg-emerald-50";
textColor = "text-emerald-900";
}
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}
<tr key={a.id} className={`${rowColor} ${textColor} transition-colors group`}>
<td className="px-4 py-3 whitespace-nowrap border-r border-gray-100">
<div className="font-black">
{dateObj.toLocaleDateString('en-IN', { day: '2-digit', month: '2-digit', year: 'numeric' })}
</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 className="text-[10px] opacity-70 font-bold uppercase mt-0.5">
{dateObj.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</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>}
{a.opportunity && <span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-600 border border-emerald-100 uppercase tracking-wider truncate max-w-[150px]" title={a.opportunity.title}>{a.opportunity.title}</span>}
{a.enquiry && <span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-600 border border-indigo-100 uppercase tracking-wider truncate max-w-[150px]" title={a.enquiry.products && a.enquiry.products.length > 0 ? a.enquiry.products.map((p: any) => p.name).join(', ') : 'Enquiry'}>{a.enquiry.products && a.enquiry.products.length > 0 ? a.enquiry.products.map((p: any) => p.name).join(', ') : 'Enquiry'}</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>}
</td>
<td className="px-4 py-3 border-r border-gray-100">
<div className="flex items-center gap-2 mb-1">
<div className="flex items-center gap-1.5 font-bold">
<span className="opacity-70">{getTypeIcon(a.type)}</span>
{a.type.replace('_', ' ')}
</div>
{a.status === 'DONE' && (
<span className="px-1.5 py-0.5 bg-emerald-200 text-emerald-800 text-[9px] font-black uppercase rounded">Done</span>
)}
<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>
)}
{a.client && <div className="text-[10px] font-bold opacity-80 truncate max-w-[200px]">🏢 {a.client.companyName || a.client.name}</div>}
{a.opportunity && <div className="text-[10px] font-semibold opacity-70 truncate max-w-[200px] mt-0.5">💼 {a.opportunity.title}</div>}
</td>
<td className="px-4 py-3 border-r border-gray-100 whitespace-nowrap">
<div className="font-bold flex items-center gap-1.5">
<User size={12} className="opacity-50" />
{a.user?.name || 'Unassigned'}
</div>
{isAdminOrGM && a.status !== 'DONE' && (
reassigning === a.id ? (
<div className="mt-1.5">
{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
className="text-[10px] p-1 border border-black/20 rounded outline-none bg-white w-20"
>
<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>
<button onClick={() => handleReassign(a.id)} className="text-[10px] bg-black/60 text-white px-1.5 py-1 rounded hover:bg-black">Go</button>
<button onClick={() => { setReassigning(null); setReassignUserId(''); }} className="text-[10px] border border-black/20 px-1.5 py-1 rounded"></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 onClick={() => { setReassigning(a.id); setReassignUserId(''); }} className="text-[9px] font-bold uppercase underline opacity-60 hover:opacity-100">
Reassign
</button>
)
)}
</div>
)}
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-2">
<p className="text-sm font-medium leading-tight">
{a.notes || <span className="opacity-50 italic text-xs">No remarks</span>}
</p>
{a.type === 'DEMO' && a.demoPersonName && (
<div className="text-[10px] opacity-80 border-l-2 border-black/20 pl-2 mt-1">
Met: <span className="font-bold">{a.demoPersonName}</span> ({a.demoContactDetails})
</div>
))}
)}
{a.status !== 'DONE' && (
<div className="mt-1">
<button
onClick={() => handleMarkDone(a)}
className="text-[10px] font-black uppercase tracking-wider px-3 py-1.5 bg-black/80 hover:bg-black text-white rounded-lg transition-all shadow-sm flex items-center gap-1 w-max"
>
<CheckCircle2 size={12} /> Mark Done
</button>
</div>
)}
</div>
</td>
</tr>
);
})
});
})()
)}
</tbody>
</table>
</div>
)}
</div>
@ -539,16 +620,21 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
<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">
<div className="grid grid-cols-5 gap-2">
{[
{ id: 'CALL', label: 'Call', icon: <Phone size={16}/>, color: 'green' },
{ id: 'MESSAGE', label: 'Msg', icon: <MessageCircle size={16}/>, color: 'cyan' },
{ id: 'DEMO_SCHEDULED', label: 'Demo Sch', icon: <Calendar size={16}/>, color: 'blue' },
{ id: 'DEMO_COMPLETED', label: 'Demo Done', icon: <CheckCircle2 size={16}/>, color: 'emerald' },
{ id: 'SECOND_DEMO', label: '2nd Demo', icon: <Presentation size={16}/>, color: 'teal' },
{ id: 'QUOTE_REQUEST', label: 'Quote Req', icon: <FileSearch size={16}/>, color: 'purple' },
{ id: 'QUOTE_SEND', label: 'Quote Send', icon: <Send size={16}/>, color: 'indigo' },
{ id: 'SECOND_QUOTE', label: '2nd Quote', icon: <FileText size={16}/>, color: 'violet' },
{ id: 'VISIT_SCHEDULED', label: 'Visit Sch', icon: <MapPin size={16}/>, color: 'orange' },
{ id: 'VISIT_COMPLETED', label: 'Visit Done', icon: <ClipboardCheck size={16}/>, color: 'red' },
{ id: 'MANAGER_HELP', label: 'Mgr Help', icon: <AlertTriangle size={16}/>, color: 'rose' },
{ id: 'PROBABILITY_UPDATE', label: 'Prob Update', icon: <TrendingUp size={16}/>, color: 'fuchsia' },
{ id: 'TIMEFRAME_UPDATE', label: 'Time Update', icon: <Clock size={16}/>, color: 'sky' },
{ id: 'NEGOTIATION', label: 'Negotiate', icon: <Handshake size={16}/>, color: 'amber' },
{ id: 'FOLLOWUP', label: 'Other', icon: <ListTodo size={16}/>, color: 'slate' },
].map(t => (
@ -559,17 +645,33 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
className={`flex flex-col items-center justify-center p-2 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-[9px] font-black mt-1 uppercase tracking-tight">{t.label}</span>
<span className="text-[9px] font-black mt-1 uppercase tracking-tight text-center leading-none">{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>
{/* Client selector — shown for all types except QUOTE (which links via opportunity) */}
{newActivity.type !== 'QUOTE' && (
<div className={initialClientId ? 'col-span-2' : ''}>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Client *</label>
<select
required
disabled={!!initialClientId}
value={newActivity.clientId}
onChange={e => setNewActivity({ ...newActivity, clientId: e.target.value, opportunityId: '' })}
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>
)}
{/* Opportunity selector — required for QUOTE, optional for demo/visit/quote types */}
{newActivity.type === 'QUOTE' ? (
<div className="col-span-2">
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Link to Opportunity *</label>
<select
required
value={newActivity.opportunityId}
@ -588,19 +690,27 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
<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>
) : ['DEMO_SCHEDULED', 'DEMO_COMPLETED', 'DEMO', 'VISIT_SCHEDULED', 'VISIT_COMPLETED', 'QUOTE_REQUEST', 'QUOTE_SEND'].includes(newActivity.type) && (
<div className={initialClientId ? 'col-span-2' : ''}>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">
Pipeline Opportunity <span className="normal-case font-normal text-gray-300">(auto-detect if blank)</span>
</label>
<select
value={newActivity.opportunityId}
onChange={e => setNewActivity({ ...newActivity, opportunityId: 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="">Auto-detect from client</option>
{opportunities
.filter(o => !newActivity.clientId || o.clientId === newActivity.clientId)
.map(o => (
<option key={o.id} value={o.id}>{o.title} [{o.stage}]</option>
))
}
</select>
</div>
)}
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Assign To *</label>
@ -696,7 +806,7 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
<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">
<form onSubmit={submitDemoFeedback} className="p-6 overflow-y-auto space-y-4 flex-1">
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Customer Feedback *</label>
<textarea
@ -780,8 +890,9 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
/>
</div>
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Suggestions</label>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Suggestions *</label>
<textarea
required
value={demoFeedback.suggestions}
onChange={e => setDemoFeedback({ ...demoFeedback, suggestions: 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"

View File

@ -1,7 +1,7 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Plus, X, Phone, MessageCircle, Calendar, CheckCircle2, FileSearch, Send, MapPin, ClipboardCheck, Handshake, ListTodo, User, Building2, Clock } from 'lucide-react';
import { Plus, X, Phone, MessageCircle, Calendar, CheckCircle2, FileSearch, Send, MapPin, ClipboardCheck, Handshake, ListTodo, User, Briefcase, Clock, Presentation, FileText, AlertTriangle, TrendingUp } from 'lucide-react';
import api from '../lib/axios';
import { useAuth } from '@/context/AuthContext';
import clsx from 'clsx';
@ -9,11 +9,12 @@ import clsx from 'clsx';
export default function FloatingEventButton() {
const { user } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const [clients, setClients] = useState<any[]>([]);
const [opportunities, setOpportunities] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
type: 'CALL',
opportunityId: '',
clientId: '',
userId: user?.id || '',
notes: '',
@ -24,18 +25,18 @@ export default function FloatingEventButton() {
useEffect(() => {
if (isOpen) {
if (clients.length === 0) fetchClients();
if (opportunities.length === 0) fetchOpportunities();
if (users.length === 0) fetchUsers();
}
}, [isOpen]);
const fetchClients = async () => {
const fetchOpportunities = async () => {
setLoading(true);
try {
const res = await api.get('/clients');
setClients(res.data);
const res = await api.get('/opportunities');
setOpportunities(res.data);
} catch (e) {
console.error('Failed to fetch clients', e);
console.error('Failed to fetch opportunities', e);
} finally {
setLoading(false);
}
@ -52,26 +53,32 @@ export default function FloatingEventButton() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.clientId || !formData.notes) {
alert('Please select a client and add notes.');
if (!formData.opportunityId || !formData.notes) {
alert('Please select an opportunity and add notes.');
return;
}
try {
const dateStr = `${formData.date}T${formData.time}:00`;
await api.post('/followups', {
...formData,
type: formData.type,
clientId: formData.clientId || undefined,
opportunityId: formData.opportunityId,
userId: formData.userId,
notes: formData.notes,
stage: formData.stage,
date: new Date(dateStr).toISOString(),
status: 'PENDING'
});
setIsOpen(false);
setFormData({
...formData,
setFormData(prev => ({
...prev,
opportunityId: '',
clientId: '',
notes: '',
date: new Date().toISOString().split('T')[0],
});
}));
alert('Event scheduled successfully!');
// Optional: Trigger a global refresh if needed
window.dispatchEvent(new CustomEvent('activityCreated'));
} catch (e) {
alert('Failed to schedule event.');
@ -83,10 +90,15 @@ export default function FloatingEventButton() {
{ id: 'MESSAGE', label: 'Message', icon: MessageCircle, color: 'text-cyan-600', bg: 'bg-cyan-50', border: 'border-cyan-200' },
{ id: 'DEMO_SCHEDULED', label: 'Demo Sch', icon: Calendar, color: 'text-blue-600', bg: 'bg-blue-50', border: 'border-blue-200' },
{ id: 'DEMO_COMPLETED', label: 'Demo Done', icon: CheckCircle2, color: 'text-emerald-600', bg: 'bg-emerald-50', border: 'border-emerald-200' },
{ id: 'SECOND_DEMO', label: '2nd Demo', icon: Presentation, color: 'text-teal-600', bg: 'bg-teal-50', border: 'border-teal-200' },
{ id: 'QUOTE_REQUEST', label: 'Quote Req', icon: FileSearch, color: 'text-purple-600', bg: 'bg-purple-50', border: 'border-purple-200' },
{ id: 'QUOTE_SEND', label: 'Quote Send', icon: Send, color: 'text-indigo-600', bg: 'bg-indigo-50', border: 'border-indigo-200' },
{ id: 'SECOND_QUOTE', label: '2nd Quote', icon: FileText, color: 'text-violet-600', bg: 'bg-violet-50', border: 'border-violet-200' },
{ id: 'VISIT_SCHEDULED', label: 'Visit Sch', icon: MapPin, color: 'text-orange-600', bg: 'bg-orange-50', border: 'border-orange-200' },
{ id: 'VISIT_COMPLETED', label: 'Visit Done', icon: ClipboardCheck, color: 'text-red-600', bg: 'bg-red-50', border: 'border-red-200' },
{ id: 'MANAGER_HELP', label: 'Mgr Help', icon: AlertTriangle, color: 'text-rose-600', bg: 'bg-rose-50', border: 'border-rose-200' },
{ id: 'PROBABILITY_UPDATE', label: 'Prob Update', icon: TrendingUp, color: 'text-fuchsia-600', bg: 'bg-fuchsia-50', border: 'border-fuchsia-200' },
{ id: 'TIMEFRAME_UPDATE', label: 'Time Update', icon: Clock, color: 'text-sky-600', bg: 'bg-sky-50', border: 'border-sky-200' },
{ id: 'NEGOTIATION', label: 'Negotiate', icon: Handshake, color: 'text-amber-600', bg: 'bg-amber-50', border: 'border-amber-200' },
{ id: 'FOLLOWUP', label: 'Other', icon: ListTodo, color: 'text-slate-600', bg: 'bg-slate-50', border: 'border-slate-200' },
];
@ -147,23 +159,42 @@ export default function FloatingEventButton() {
})}
</div>
{/* Client & User */}
<div className="grid grid-cols-2 gap-4">
{/* Opportunity & User */}
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<label className="flex items-center gap-2 text-[11px] font-black text-slate-400 uppercase tracking-widest ml-1">
<Building2 size={12} /> Select Client
<Briefcase size={12} /> Select Opportunity
</label>
<select
required
value={formData.clientId}
onChange={(e) => setFormData({ ...formData, clientId: e.target.value })}
value={formData.opportunityId}
onChange={(e) => {
const opp = opportunities.find((o: any) => o.id === e.target.value);
setFormData(prev => ({
...prev,
opportunityId: e.target.value,
clientId: opp?.client?.id || opp?.clientId || '',
}));
}}
className="w-full bg-slate-50 border-none rounded-2xl px-4 py-3.5 text-sm font-semibold focus:ring-2 focus:ring-odoo-primary outline-none transition-all appearance-none cursor-pointer"
>
<option value="">Choose...</option>
{clients.map(c => (
<option key={c.id} value={c.id}>{c.companyName || c.name}</option>
<option value="">Choose opportunity...</option>
{loading ? (
<option disabled>Loading...</option>
) : opportunities.map((o: any) => (
<option key={o.id} value={o.id}>
{o.title || o.name} {o.client?.companyName ? `${o.client.companyName}` : ''}
</option>
))}
</select>
{formData.opportunityId && (() => {
const opp = opportunities.find((o: any) => o.id === formData.opportunityId);
return opp ? (
<p className="text-[11px] text-slate-400 font-semibold ml-1">
Client: <span className="text-slate-600 font-bold">{opp.client?.companyName || opp.client?.name || '—'}</span>
</p>
) : null;
})()}
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-[11px] font-black text-slate-400 uppercase tracking-widest ml-1">

View File

@ -71,6 +71,16 @@ function MapController({ team, clients, fitCounter, selectedRoute, selectedUserI
const bounds = L.latLngBounds(points);
map.fitBounds(bounds, { padding: [100, 100], maxZoom: 14, animate: true });
}
return () => {
try {
if (map && (map as any)._container) {
map.stop();
}
} catch (err) {
// Ignore Leaflet unmount animation errors
}
};
}, [fitCounter, map, team, clients, selectedRoute, selectedUserId]);
return null;

View File

@ -255,20 +255,20 @@ export default function ManagerDashboard() {
y: {
type: 'linear' as const,
position: 'left' as const,
title: { display: true, text: 'Deals Count', font: { family: 'Inter', weight: 'bold' } },
title: { display: true, text: 'Deals Count', font: { family: 'Inter', weight: 'bold' as const } },
grid: { color: 'rgba(0, 0, 0, 0.05)' },
ticks: { font: { family: 'Inter', size: 10 } }
},
y1: {
type: 'linear' as const,
position: 'right' as const,
title: { display: true, text: 'Value (₹ Lakhs)', font: { family: 'Inter', weight: 'bold' } },
title: { display: true, text: 'Value (₹ Lakhs)', font: { family: 'Inter', weight: 'bold' as const } },
grid: { drawOnChartArea: false }, // only draw grid lines for first y-axis
ticks: { font: { family: 'Inter', size: 10 } }
},
x: {
grid: { display: false },
ticks: { font: { family: 'Inter', size: 11, weight: 'bold' } }
ticks: { font: { family: 'Inter', size: 11, weight: 'bold' as const } }
}
}
};

View File

@ -0,0 +1,905 @@
'use client';
import React, { useEffect, useState, useMemo } from 'react';
import api from '../lib/axios';
import { useAuth } from '@/context/AuthContext';
import {
Phone, MessageCircle, Calendar, CheckCircle2, FileSearch, Send,
MapPin, ClipboardCheck, Handshake, AlertTriangle, TrendingUp, Clock,
HelpCircle, ListTodo, Plus, CalendarClock, User, X, Check, FileText
} from 'lucide-react';
import { format } from 'date-fns';
interface Activity {
id: string;
type: string;
notes: string;
status: string;
date: string;
createdAt: string;
user?: { id: string; name: string };
client?: { id: string; name: string; companyName?: string };
demoPersonName?: string;
demoContactDetails?: string;
customerFeedback?: string;
requirementDetails?: string;
suggestions?: string;
budget?: string;
expectedClosingTimeline?: string;
competitorInfo?: string;
staffRemarks?: string;
customerCommitments?: string;
caCsDetails?: string;
}
interface OpportunityActivityChainProps {
opportunityId: string;
clientId: string;
onActivityUpdated?: () => void;
}
const MANDATORY_FEEDBACK_TYPES = ['DEMO', 'DEMO_SCHEDULED', 'DEMO_COMPLETED', 'VISIT_SCHEDULED', 'VISIT_COMPLETED', 'SECOND_DEMO'];
const defaultFeedback = {
remarks: '',
demoPersonName: '',
demoContactDetails: '',
customerFeedback: '',
requirementDetails: '',
suggestions: '',
budget: '',
expectedClosingTimeline: '',
competitorInfo: '',
staffRemarks: '',
customerCommitments: '',
caCsDetails: '',
};
export default function OpportunityActivityChain({ opportunityId, clientId, onActivityUpdated }: OpportunityActivityChainProps) {
const { user } = useAuth();
const [activities, setActivities] = useState<Activity[]>([]);
const [users, setUsers] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
// Inline forms state
const [activeActionId, setActiveActionId] = useState<string | null>(null); // matches step.id
const [actionType, setActionType] = useState<'schedule' | 'complete' | 'reschedule' | 'view' | null>(null);
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
const [selectedStepId, setSelectedStepId] = useState<string | null>('CALL');
// Form inputs
const [scheduleForm, setScheduleForm] = useState({ date: new Date().toISOString().split('T')[0], time: '10:00', notes: '', userId: '' });
const [feedbackForm, setFeedbackForm] = useState({ ...defaultFeedback });
const [rescheduleForm, setRescheduleForm] = useState({ date: '', time: '10:00', reason: '' });
// 15 Standard Pipeline Events definition ordered for 5x3 grid
const EVENT_STEPS = useMemo(() => [
{ id: 'CALL', label: 'Initial Call', icon: Phone, color: 'text-green-500', bg: 'bg-green-50', border: 'border-green-200', types: ['CALL'] },
{ id: 'MESSAGE', label: 'WhatsApp Message', icon: MessageCircle, color: 'text-cyan-500', bg: 'bg-cyan-50', border: 'border-cyan-200', types: ['MESSAGE'] },
{ id: 'DEMO_SCHEDULED', label: 'Demo Scheduled', icon: Calendar, color: 'text-blue-500', bg: 'bg-blue-50', border: 'border-blue-200', types: ['DEMO_SCHEDULED'] },
{ id: 'DEMO_COMPLETED', label: 'Demo Completed', icon: CheckCircle2, color: 'text-emerald-500', bg: 'bg-emerald-50', border: 'border-emerald-200', types: ['DEMO_COMPLETED', 'DEMO'] },
{ id: 'SECOND_DEMO', label: 'Second Demo', icon: PresentationIcon, color: 'text-teal-500', bg: 'bg-teal-50', border: 'border-teal-200', types: ['SECOND_DEMO'] },
{ id: 'QUOTE_REQUEST', label: 'Quote Request', icon: FileSearch, color: 'text-purple-500', bg: 'bg-purple-50', border: 'border-purple-200', types: ['QUOTE_REQUEST'] },
{ id: 'QUOTE_SEND', label: 'Quote Sent', icon: Send, color: 'text-indigo-500', bg: 'bg-indigo-50', border: 'border-indigo-200', types: ['QUOTE_SEND', 'QUOTE'] },
{ id: 'SECOND_QUOTE', label: 'Second Quote', icon: FileText, color: 'text-violet-500', bg: 'bg-violet-50', border: 'border-violet-200', types: ['SECOND_QUOTE'] },
{ id: 'VISIT_SCHEDULED', label: 'Visit Scheduled', icon: MapPin, color: 'text-orange-500', bg: 'bg-orange-50', border: 'border-orange-200', types: ['VISIT_SCHEDULED'] },
{ id: 'VISIT_COMPLETED', label: 'Visit Completed', icon: ClipboardCheck, color: 'text-red-500', bg: 'bg-red-50', border: 'border-red-200', types: ['VISIT_COMPLETED'] },
{ id: 'MANAGER_HELP', label: 'Manager Help', icon: AlertTriangle, color: 'text-rose-500', bg: 'bg-rose-50', border: 'border-rose-200', types: ['MANAGER_HELP'] },
{ id: 'PROBABILITY_UPDATE', label: 'Probability Sync', icon: TrendingUp, color: 'text-fuchsia-500', bg: 'bg-fuchsia-50', border: 'border-fuchsia-200', types: ['PROBABILITY_UPDATE'] },
{ id: 'TIMEFRAME_UPDATE', label: 'Timeframe Sync', icon: Clock, color: 'text-sky-500', bg: 'bg-sky-50', border: 'border-sky-200', types: ['TIMEFRAME_UPDATE'] },
{ id: 'NEGOTIATION', label: 'Negotiation', icon: Handshake, color: 'text-amber-500', bg: 'bg-amber-50', border: 'border-amber-200', types: ['NEGOTIATION'] },
{ id: 'FOLLOWUP', label: 'Follow-up / Other', icon: ListTodo, color: 'text-slate-500', bg: 'bg-slate-50', border: 'border-slate-200', types: ['FOLLOWUP'] },
], []);
// Sync current user into schedule form once auth context resolves
useEffect(() => {
if (user?.id) {
setScheduleForm(prev => ({ ...prev, userId: prev.userId || user.id }));
}
}, [user?.id]);
useEffect(() => {
fetchActivities();
api.get('/users').then(r => setUsers(r.data)).catch(() => {});
}, [opportunityId]);
const fetchActivities = async () => {
if (!opportunityId) return;
try {
setLoading(true);
const res = await api.get('/followups', { params: { opportunityId } });
setActivities(res.data);
} catch (e) {
console.error('Failed to load opportunity activities chain', e);
} finally {
setLoading(false);
}
};
// Helper component for Presentation Icon
function PresentationIcon(props: any) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M2 3h20" />
<path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3" />
<path d="m7 21 5-5 5 5" />
</svg>
);
}
// Map fetched activities to their pre-populated checklist/node slots
const mappedSteps = useMemo(() => {
return EVENT_STEPS.map(step => {
// Collect ALL activities matching this step's types, sorted newest first
const allActivities = activities
.filter(a => step.types.includes(a.type))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const hasDone = allActivities.some(a => a.status === 'DONE');
const hasPending = allActivities.some(a => a.status === 'PENDING');
// Overall badge status: DONE if any done, PENDING if any pending, else NOT_STARTED
const status = hasDone ? 'DONE' : hasPending ? 'PENDING' : 'NOT_STARTED';
return {
...step,
status,
allActivities,
// keep legacy `activity` pointing to the most recent one for backward compat
activity: allActivities[0] ?? null,
};
});
}, [activities, EVENT_STEPS]);
const handleQuickActivate = (stepId: string) => {
setActiveActionId(stepId);
setActionType('schedule');
setScheduleForm({
date: new Date().toISOString().split('T')[0],
time: '10:00',
notes: `Auto-scheduled ${EVENT_STEPS.find(s => s.id === stepId)?.label}`,
userId: user?.id ?? scheduleForm.userId
});
};
const handleCompleteClick = (stepId: string, activity: Activity) => {
setActiveActionId(stepId);
setActionType('complete');
setSelectedActivity(activity);
setFeedbackForm({ ...defaultFeedback });
};
const handleRescheduleClick = (stepId: string, activity: Activity) => {
setActiveActionId(stepId);
setActionType('reschedule');
setSelectedActivity(activity);
setRescheduleForm({ date: '', time: '10:00', reason: '' });
};
const handleViewClick = (stepId: string, activity: Activity) => {
setActiveActionId(stepId);
setActionType('view');
setSelectedActivity(activity);
};
const submitSchedule = async (e: React.FormEvent, type: string) => {
e.preventDefault();
setSubmitting(true);
try {
const dateStr = `${scheduleForm.date}T${scheduleForm.time}:00`;
await api.post('/followups', {
type,
clientId,
opportunityId,
userId: scheduleForm.userId || user?.id,
notes: scheduleForm.notes,
stage: 'POTENTIAL',
date: new Date(dateStr).toISOString(),
status: 'PENDING'
});
setActiveActionId(null);
setActionType(null);
fetchActivities();
if (onActivityUpdated) onActivityUpdated();
} catch (e) {
alert('Failed to schedule activity.');
} finally {
setSubmitting(false);
}
};
const submitComplete = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedActivity) return;
const isMandatory = MANDATORY_FEEDBACK_TYPES.includes(selectedActivity.type);
if (isMandatory) {
const requiredFields: (keyof typeof feedbackForm)[] = [
'customerFeedback', 'requirementDetails', 'suggestions', 'budget',
'expectedClosingTimeline', 'competitorInfo', 'staffRemarks',
'customerCommitments', 'caCsDetails'
];
const missing = requiredFields.filter(f => !feedbackForm[f]);
if (missing.length > 0) {
alert(`Please fill all mandatory feedback fields:\n${missing.map(f => f.replace(/([A-Z])/g, ' $1')).join(', ')}`);
return;
}
}
setSubmitting(true);
try {
const payload: Record<string, any> = { status: 'DONE' };
if (feedbackForm.remarks) payload.notes = feedbackForm.remarks;
if (isMandatory) {
Object.assign(payload, {
demoPersonName: feedbackForm.demoPersonName,
demoContactDetails: feedbackForm.demoContactDetails,
customerFeedback: feedbackForm.customerFeedback,
requirementDetails: feedbackForm.requirementDetails,
suggestions: feedbackForm.suggestions,
budget: feedbackForm.budget,
expectedClosingTimeline: feedbackForm.expectedClosingTimeline,
competitorInfo: feedbackForm.competitorInfo,
staffRemarks: feedbackForm.staffRemarks,
customerCommitments: feedbackForm.customerCommitments,
caCsDetails: feedbackForm.caCsDetails,
});
}
await api.patch(`/followups/${selectedActivity.id}`, payload);
setActiveActionId(null);
setActionType(null);
fetchActivities();
if (onActivityUpdated) onActivityUpdated();
} catch (e) {
alert('Failed to mark event as done.');
} finally {
setSubmitting(false);
}
};
const submitReschedule = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedActivity || !rescheduleForm.date) {
alert('Please specify the new date.');
return;
}
setSubmitting(true);
try {
const newDate = new Date(`${rescheduleForm.date}T${rescheduleForm.time}:00`).toISOString();
await api.patch(`/followups/${selectedActivity.id}`, {
date: newDate,
status: 'PENDING',
...(rescheduleForm.reason ? { notes: rescheduleForm.reason } : {})
});
setActiveActionId(null);
setActionType(null);
fetchActivities();
if (onActivityUpdated) onActivityUpdated();
} catch (e) {
alert('Failed to reschedule event.');
} finally {
setSubmitting(false);
}
};
if (loading) return <div className="p-8 text-center text-gray-400 font-bold animate-pulse">Loading Activity Chain Board...</div>;
return (
<div className="flex flex-col h-full bg-slate-50 rounded-2xl border border-gray-200 overflow-hidden shadow-inner">
{/* Full-width interactive 15-event list checklist */}
<div className="w-full overflow-y-auto p-6 space-y-3 custom-scrollbar">
<div className="flex justify-between items-center mb-4">
<div>
<h4 className="text-xs font-black uppercase text-slate-400 tracking-wider">Opportunity Activity Chain Checklist</h4>
<p className="text-[10px] text-gray-400 font-bold uppercase mt-0.5">Track and complete all stages directly</p>
</div>
<span className="text-[10px] font-black bg-odoo-primary text-white px-3 py-1 rounded-full shadow-sm">
{mappedSteps.filter(s => s.status === 'DONE').length} / {mappedSteps.length} Completed
</span>
</div>
<div className="grid grid-cols-5 gap-4">
{mappedSteps.map((step, idx) => {
const Icon = step.icon;
const isDone = step.status === 'DONE';
const isPending = step.status === 'PENDING';
const isSelected = selectedStepId === step.id;
// Custom styling matching the premium layout in screenshot
let tileClasses = "flex flex-col items-center justify-center p-3 aspect-square rounded-2xl border-2 transition-all cursor-pointer select-none relative ";
let iconClasses = "w-5 h-5 mb-2 ";
let textClasses = "text-[9px] font-extrabold tracking-wide text-center uppercase ";
if (isDone) {
// Completed/Active: green border, light green background, green icon and text
tileClasses += "bg-emerald-50/50 border-emerald-300 text-emerald-600 hover:bg-emerald-100/50 shadow-sm";
iconClasses += "text-emerald-500";
textClasses += "text-emerald-600";
} else if (isPending) {
// Scheduled: subtle amber/blue highlight
tileClasses += "bg-amber-50/50 border-amber-300 text-amber-600 hover:bg-amber-100/50 shadow-sm animate-pulse";
iconClasses += "text-amber-500";
textClasses += "text-amber-600";
} else {
// Not started: neutral slate
tileClasses += "bg-[#f8f9fa] border-transparent text-[#8e9bb2] hover:bg-slate-100 hover:border-slate-200";
iconClasses += "text-[#8e9bb2]";
textClasses += "text-[#8e9bb2]";
}
if (isSelected) {
tileClasses += " ring-2 ring-offset-2 ring-odoo-primary border-transparent";
}
// Short labels to match the screenshot exactly
const shortLabels: Record<string, string> = {
CALL: 'CALL',
MESSAGE: 'MESSAGE',
DEMO_SCHEDULED: 'DEMO SCH',
DEMO_COMPLETED: 'DEMO DONE',
SECOND_DEMO: '2ND DEMO',
QUOTE_REQUEST: 'QUOTE REQ',
QUOTE_SEND: 'QUOTE SEND',
SECOND_QUOTE: '2ND QUOTE',
VISIT_SCHEDULED: 'VISIT SCH',
VISIT_COMPLETED: 'VISIT DONE',
MANAGER_HELP: 'MGR HELP',
PROBABILITY_UPDATE: 'PROB UPDATE',
TIMEFRAME_UPDATE: 'TIME UPDATE',
NEGOTIATION: 'NEGOTIATE',
FOLLOWUP: 'OTHER'
};
const displayLabel = shortLabels[step.id] || step.label;
return (
<div
key={step.id}
className={tileClasses}
onClick={() => {
setSelectedStepId(step.id);
// If no activities, also automatically trigger scheduler for quick UX!
if (step.allActivities.length === 0) {
handleQuickActivate(step.id);
}
}}
>
<Icon className={iconClasses} strokeWidth={2.5} />
<span className={textClasses}>{displayLabel}</span>
{step.allActivities.length > 1 && (
<span className="absolute top-1 right-1 text-[8px] font-black px-1 bg-indigo-100 text-indigo-600 rounded">
{step.allActivities.length}x
</span>
)}
</div>
);
})}
</div>
{/* Selected Step Details / Action Area */}
{selectedStepId && (() => {
const step = mappedSteps.find(s => s.id === selectedStepId);
if (!step) return null;
const Icon = step.icon;
const isDone = step.status === 'DONE';
const isPending = step.status === 'PENDING';
const hasAny = step.allActivities.length > 0;
return (
<div className="border border-gray-200 bg-white rounded-2xl p-4 shadow-sm space-y-3 mt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
isDone ? 'bg-emerald-500 text-white shadow-sm' : isPending ? 'bg-amber-400 text-white animate-pulse' : 'bg-slate-100 text-slate-400'
}`}>
<Icon size={18} />
</div>
<div>
<h4 className="text-xs font-black text-gray-800 uppercase tracking-wider">{step.label}</h4>
<div className="flex items-center gap-1.5 mt-0.5">
{isDone ? (
<span className="text-[9px] font-black px-1.5 bg-emerald-100 text-emerald-700 border border-emerald-200 rounded uppercase">Done</span>
) : isPending ? (
<span className="text-[9px] font-black px-1.5 bg-amber-100 text-amber-700 border border-amber-200 rounded uppercase animate-pulse">Scheduled</span>
) : (
<span className="text-[9px] font-black px-1.5 bg-gray-100 text-gray-400 border border-gray-200 rounded uppercase">Not Started</span>
)}
</div>
</div>
</div>
<button
onClick={() => handleQuickActivate(step.id)}
className="text-[10px] font-black border border-odoo-primary/20 text-odoo-primary hover:bg-odoo-primary hover:text-white px-3 py-1.5 rounded-lg bg-white transition-all shadow-sm flex items-center gap-0.5"
>
<Plus size={12} /> {hasAny ? 'Activate / Log Again' : 'Activate Stage'}
</button>
</div>
{/* List of recorded activities for this selected step */}
{step.allActivities.length > 0 ? (
<div className="space-y-2 pt-2 border-t border-dashed border-gray-100">
<p className="text-[9px] font-black text-slate-400 uppercase tracking-wider">Recorded Stage Events ({step.allActivities.length})</p>
<div className="space-y-1.5 max-h-48 overflow-y-auto custom-scrollbar pr-1">
{step.allActivities.map((act, aIdx) => (
<div
key={act.id}
className={`flex items-center gap-2 p-2.5 rounded-xl border ${
act.status === 'DONE'
? 'bg-emerald-50/50 border-emerald-100'
: 'bg-amber-50/50 border-amber-100'
}`}
>
<div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
act.status === 'DONE' ? 'bg-emerald-500' : 'bg-amber-400 animate-pulse'
}`} />
<div className="flex-1 min-w-0">
<p className="text-[10px] font-bold text-gray-700 truncate">
Event #{step.allActivities.length - aIdx} ·{' '}
{act.status === 'DONE'
? `Completed ${format(new Date(act.date), 'MMM d, h:mm a')}`
: `Scheduled ${format(new Date(act.date), 'MMM d, h:mm a')}`}
{act.user?.name ? ` by ${act.user.name}` : ''}
</p>
{act.notes && (
<p className="text-[10px] text-gray-400 mt-1 truncate bg-white/60 px-1.5 py-0.5 rounded border border-gray-100/50">{act.notes}</p>
)}
</div>
<div className="flex gap-1 flex-shrink-0">
{act.status === 'DONE' ? (
<button
onClick={() => handleViewClick(step.id, act)}
className="text-[9px] font-black border border-emerald-200 text-emerald-700 hover:bg-emerald-100 px-2.5 py-1 rounded bg-white transition-all shadow-sm"
>
Logs
</button>
) : (
<>
<button
onClick={() => handleCompleteClick(step.id, act)}
className="text-[9px] font-black bg-emerald-500 hover:bg-emerald-600 text-white px-2.5 py-1 rounded transition-all shadow-sm"
>
Done
</button>
<button
onClick={() => handleRescheduleClick(step.id, act)}
className="text-[9px] font-black border border-amber-200 text-amber-700 hover:bg-amber-100 px-2.5 py-1 rounded bg-white transition-all shadow-sm"
>
Resched
</button>
</>
)}
</div>
</div>
))}
</div>
</div>
) : (
<div className="text-center py-4 text-slate-400 text-[10px] font-bold bg-slate-50 rounded-xl border border-dashed border-slate-200">
No events recorded for this stage yet. Click "Activate Stage" to schedule or log one.
</div>
)}
</div>
);
})()}
{/* ── FULL ACTIVITY FLOW TIMELINE ── */}
{activities.length > 0 && (() => {
// All activities sorted chronologically (oldest first)
const timelineItems = [...activities].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
return (
<div className="border border-gray-200 bg-white rounded-2xl p-6 mt-6 shadow-sm">
<div className="flex items-center gap-2 mb-4">
<div className="w-1.5 h-4 rounded-full bg-odoo-primary" />
<h4 className="text-xs font-black uppercase text-slate-600 tracking-wider">Full Activity Flow</h4>
<span className="text-[10px] font-black bg-slate-100 text-slate-500 px-2 py-0.5 rounded-full ml-1">
{timelineItems.length} events
</span>
</div>
<div className="relative">
{/* Vertical connector line */}
<div className="absolute left-[13px] top-0 bottom-0 w-px bg-gradient-to-b from-odoo-primary/30 via-gray-200 to-transparent" />
<div className="space-y-3">
{timelineItems.map((act, idx) => {
const step = EVENT_STEPS.find(s => s.types.includes(act.type));
const Icon = step?.icon ?? ListTodo;
const isDone = act.status === 'DONE';
const isPending = act.status === 'PENDING';
return (
<div key={act.id} className="flex gap-3 relative">
{/* Timeline dot */}
<div className={`flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center z-10 border-2 ${
isDone
? 'bg-emerald-500 border-emerald-400 text-white'
: isPending
? 'bg-amber-400 border-amber-300 text-white'
: 'bg-gray-200 border-gray-300 text-gray-400'
}`}>
{isDone ? <Check size={12} /> : <Icon size={11} />}
</div>
{/* Event card */}
<div className={`flex-1 rounded-xl border p-3 text-xs ${
isDone
? 'bg-emerald-50/60 border-emerald-100'
: isPending
? 'bg-amber-50/60 border-amber-200'
: 'bg-gray-50 border-gray-100'
}`}>
{/* Top row: event name + status + index */}
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-black text-gray-400">#{idx + 1}</span>
<span className="font-black text-gray-800 text-[11px]">
{step?.label ?? act.type}
</span>
<span className={`text-[9px] font-black px-1.5 rounded uppercase border ${
isDone
? 'bg-emerald-100 text-emerald-700 border-emerald-200'
: isPending
? 'bg-amber-100 text-amber-700 border-amber-200 animate-pulse'
: 'bg-gray-100 text-gray-400 border-gray-200'
}`}>
{isDone ? 'Completed' : isPending ? 'Scheduled' : act.status}
</span>
</div>
<span className="text-[9px] text-gray-400 font-semibold whitespace-nowrap">
{format(new Date(act.createdAt), 'MMM d, yyyy · h:mm a')}
</span>
</div>
{/* Details row: assignee + scheduled date */}
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1">
<div className="flex items-center gap-1 text-[10px] text-gray-500">
<User size={10} className="text-odoo-primary/60" />
<span className="font-bold">
{act.user?.name ?? 'Unassigned'}
</span>
</div>
<div className="flex items-center gap-1 text-[10px] text-gray-500">
<CalendarClock size={10} className="text-odoo-primary/60" />
<span>
{isDone ? 'Done on' : 'Scheduled for'}{' '}
<span className="font-bold">
{format(new Date(act.date), 'MMM d, yyyy · h:mm a')}
</span>
</span>
</div>
</div>
{/* Notes */}
{act.notes && (
<p className="mt-1.5 text-[10px] text-gray-500 leading-relaxed bg-white/80 border border-gray-100 rounded-lg px-2 py-1.5 whitespace-pre-wrap">
{act.notes}
</p>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
);
})()}
</div>
{/* ── MODAL OVERLAY SHEET FOR CORE ACTIONS ── */}
{activeActionId !== null && actionType !== null && (
<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) { setActiveActionId(null); setActionType(null); setSelectedActivity(null); } }}
>
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col animate-in fade-in zoom-in duration-200 max-h-[85vh]">
{/* Form Header */}
<div className="flex justify-between items-center px-6 py-4 bg-slate-800 text-white shrink-0">
<div>
<h5 className="font-black text-sm uppercase tracking-wide">
{actionType === 'schedule' && `Schedule ${EVENT_STEPS.find(s => s.id === activeActionId)?.label}`}
{actionType === 'complete' && `Mark Done: ${EVENT_STEPS.find(s => s.id === activeActionId)?.label}`}
{actionType === 'reschedule' && `Reschedule: ${EVENT_STEPS.find(s => s.id === activeActionId)?.label}`}
{actionType === 'view' && `Activity Logs: ${EVENT_STEPS.find(s => s.id === activeActionId)?.label}`}
</h5>
<p className="text-[10px] text-white/60 font-bold uppercase mt-0.5">Opportunity Chain Action</p>
</div>
<button
onClick={() => { setActiveActionId(null); setActionType(null); setSelectedActivity(null); }}
className="p-1.5 hover:bg-white/10 rounded-lg text-white/70 hover:text-white transition-colors"
>
<X size={18} />
</button>
</div>
{/* Form / Log Details scrollable content */}
<div className="flex-1 overflow-y-auto p-6 space-y-4 custom-scrollbar">
{/* ── SCHEDULE FORM ── */}
{actionType === 'schedule' && (
<form onSubmit={(e) => submitSchedule(e, activeActionId)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase">Date *</label>
<input
type="date" required
value={scheduleForm.date}
onChange={e => setScheduleForm(prev => ({ ...prev, date: e.target.value }))}
className="w-full p-2.5 bg-slate-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary focus:bg-white text-sm"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase">Time *</label>
<input
type="time" required
value={scheduleForm.time}
onChange={e => setScheduleForm(prev => ({ ...prev, time: e.target.value }))}
className="w-full p-2.5 bg-slate-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary focus:bg-white text-sm"
/>
</div>
</div>
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase">Assignee *</label>
<select
required
value={scheduleForm.userId}
onChange={e => setScheduleForm(prev => ({ ...prev, userId: e.target.value }))}
className="w-full p-2.5 bg-slate-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary focus:bg-white text-sm"
>
<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}</option>
))}
</select>
</div>
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase">Event Notes / Agenda *</label>
<textarea
required
rows={4}
placeholder="What is the objective of this event?"
value={scheduleForm.notes}
onChange={e => setScheduleForm(prev => ({ ...prev, notes: e.target.value }))}
className="w-full p-3 bg-slate-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary focus:bg-white text-sm resize-none"
/>
</div>
<button
type="submit"
disabled={submitting}
className="w-full py-3.5 bg-odoo-primary hover:bg-odoo-primary/95 text-white font-black rounded-xl text-xs uppercase tracking-widest shadow-md transition-all disabled:opacity-60 mt-4"
>
{submitting ? 'Scheduling...' : 'Schedule Event'}
</button>
</form>
)}
{/* ── COMPLETE EVENT FORM ── */}
{actionType === 'complete' && selectedActivity && (
<form onSubmit={submitComplete} className="space-y-4">
{selectedActivity.notes && (
<div className="bg-slate-50 rounded-xl p-3 border border-gray-100 text-xs text-gray-600 mb-2">
<span className="font-bold block text-gray-700 uppercase text-[9px] mb-1">Scheduled Agenda:</span>
{selectedActivity.notes}
</div>
)}
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase">Completion Remarks / Notes</label>
<textarea
rows={2}
placeholder="Log summary of details..."
value={feedbackForm.remarks}
onChange={e => setFeedbackForm(prev => ({ ...prev, remarks: e.target.value }))}
className="w-full p-2.5 bg-slate-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-emerald-500 focus:bg-white text-sm resize-none"
/>
</div>
{MANDATORY_FEEDBACK_TYPES.includes(selectedActivity.type) && (
<div className="space-y-3 pt-3 border-t border-dashed border-gray-200">
<div className="text-[10px] font-black text-rose-500 uppercase tracking-widest flex items-center gap-1">
<ClipboardCheck size={12} /> Mandatory Stage feedback details
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] font-bold text-gray-600 mb-0.5">Person Met</label>
<input
type="text"
value={feedbackForm.demoPersonName}
onChange={e => setFeedbackForm(prev => ({ ...prev, demoPersonName: e.target.value }))}
className="w-full p-2 bg-slate-50 border border-gray-200 rounded-lg text-xs"
/>
</div>
<div>
<label className="block text-[10px] font-bold text-gray-600 mb-0.5">Contact Details</label>
<input
type="text"
value={feedbackForm.demoContactDetails}
onChange={e => setFeedbackForm(prev => ({ ...prev, demoContactDetails: e.target.value }))}
className="w-full p-2 bg-slate-50 border border-gray-200 rounded-lg text-xs"
/>
</div>
</div>
{[
{ key: 'customerFeedback', label: 'Customer Feedback *' },
{ key: 'requirementDetails', label: 'Requirement Details *' },
{ key: 'suggestions', label: 'Suggestions / Recommendations *' },
{ key: 'budget', label: 'Budget Size *' },
{ key: 'expectedClosingTimeline', label: 'Expected Closing Timeline *' },
{ key: 'competitorInfo', label: 'Competitor Information *' },
{ key: 'staffRemarks', label: 'Staff Remarks *' },
{ key: 'customerCommitments', label: 'Customer Commitments *' },
{ key: 'caCsDetails', label: 'CA / CS Reference details *' },
].map(f => (
<div key={f.key}>
<label className="block text-[10px] font-bold text-gray-600 mb-0.5">{f.label}</label>
<textarea
rows={1.5}
value={feedbackForm[f.key as keyof typeof feedbackForm]}
onChange={e => setFeedbackForm(prev => ({ ...prev, [f.key]: e.target.value }))}
className="w-full p-2 bg-slate-50 border border-gray-200 rounded-lg text-xs outline-none focus:ring-1 focus:ring-emerald-500 focus:bg-white resize-none"
/>
</div>
))}
</div>
)}
<button
type="submit"
disabled={submitting}
className="w-full py-3.5 bg-emerald-500 hover:bg-emerald-600 text-white font-black rounded-xl text-xs uppercase tracking-widest shadow-md transition-all disabled:opacity-60 mt-4"
>
{submitting ? 'Completing...' : 'Mark Done & Close'}
</button>
</form>
)}
{/* ── RESCHEDULE FORM ── */}
{actionType === 'reschedule' && selectedActivity && (
<form onSubmit={submitReschedule} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase">New Date *</label>
<input
type="date" required
min={new Date().toISOString().split('T')[0]}
value={rescheduleForm.date}
onChange={e => setRescheduleForm(prev => ({ ...prev, date: e.target.value }))}
className="w-full p-2.5 bg-slate-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-amber-500 focus:bg-white text-sm"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase">New Time *</label>
<input
type="time" required
value={rescheduleForm.time}
onChange={e => setRescheduleForm(prev => ({ ...prev, time: e.target.value }))}
className="w-full p-2.5 bg-slate-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-amber-500 focus:bg-white text-sm"
/>
</div>
</div>
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase">Reason for Rescheduling</label>
<textarea
rows={4}
placeholder="Specify why this event needs rescheduling..."
value={rescheduleForm.reason}
onChange={e => setRescheduleForm(prev => ({ ...prev, reason: e.target.value }))}
className="w-full p-3 bg-slate-50 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-amber-500 focus:bg-white text-sm resize-none"
/>
</div>
<button
type="submit"
disabled={submitting}
className="w-full py-3.5 bg-amber-500 hover:bg-amber-600 text-white font-black rounded-xl text-xs uppercase tracking-widest shadow-md transition-all disabled:opacity-60 mt-4"
>
{submitting ? 'Saving...' : 'Confirm Reschedule'}
</button>
</form>
)}
{/* ── DETAILS VIEW (COMPLETED) ── */}
{actionType === 'view' && selectedActivity && (
<div className="space-y-4">
<div className="bg-emerald-50 border border-emerald-100 rounded-xl p-3 text-xs text-emerald-800 font-semibold flex gap-2">
<CheckCircle2 size={16} className="text-emerald-500 shrink-0 mt-0.5" />
Completed activity logs closed successfully.
</div>
<div className="grid grid-cols-2 gap-4 text-xs">
<div>
<span className="text-gray-400 font-bold block uppercase text-[9px] mb-0.5">Completed On</span>
<p className="font-semibold text-gray-700 bg-slate-50 p-2 rounded border border-gray-100">
{format(new Date(selectedActivity.date), 'MMM d, yyyy h:mm a')}
</p>
</div>
<div>
<span className="text-gray-400 font-bold block uppercase text-[9px] mb-0.5">Assigned Teammate</span>
<p className="font-semibold text-gray-700 bg-slate-50 p-2 rounded border border-gray-100">
{selectedActivity.user?.name || 'Unassigned'}
</p>
</div>
</div>
<div>
<span className="text-gray-400 font-bold block uppercase text-[9px] mb-0.5">Completion Notes</span>
<p className="font-semibold text-gray-700 bg-slate-50 p-3 rounded-xl border border-gray-100 text-xs whitespace-pre-line leading-relaxed">
{selectedActivity.notes || 'No closing remarks provided.'}
</p>
</div>
{MANDATORY_FEEDBACK_TYPES.includes(selectedActivity.type) && (
<div className="space-y-3 pt-3 border-t border-dashed border-gray-100">
<span className="text-[10px] font-black text-emerald-600 uppercase tracking-widest block">Mandatory feedback details</span>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-gray-400 font-bold block uppercase text-[9px] mb-0.5">Person Met</span>
<p className="font-semibold text-gray-700 bg-slate-50 p-2 rounded border border-gray-100">{selectedActivity.demoPersonName || '—'}</p>
</div>
<div>
<span className="text-gray-400 font-bold block uppercase text-[9px] mb-0.5">Contact Details</span>
<p className="font-semibold text-gray-700 bg-slate-50 p-2 rounded border border-gray-100">{selectedActivity.demoContactDetails || '—'}</p>
</div>
</div>
{ [
{ key: 'customerFeedback', label: 'Customer Feedback' },
{ key: 'requirementDetails', label: 'Requirement Details' },
{ key: 'suggestions', label: 'Suggestions / Recommendations' },
{ key: 'budget', label: 'Budget Size' },
{ key: 'expectedClosingTimeline', label: 'Expected Closing Timeline' },
{ key: 'competitorInfo', label: 'Competitor Information' },
{ key: 'staffRemarks', label: 'Staff Remarks' },
{ key: 'customerCommitments', label: 'Customer Commitments' },
{ key: 'caCsDetails', label: 'CA / CS Reference details' },
].map(f => {
const val = selectedActivity[f.key as keyof Activity];
const displayVal = typeof val === 'string' ? val : '—';
return (
<div key={f.key}>
<span className="text-gray-400 font-bold block uppercase text-[9px] mb-0.5">{f.label}</span>
<p className="font-semibold text-gray-700 bg-slate-50 p-2.5 rounded-lg border border-gray-100 text-xs leading-normal">
{displayVal}
</p>
</div>
);
})}
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -24,6 +24,7 @@ import { useAuth } from '@/context/AuthContext';
import api from '@/lib/axios';
import ClientModal from './ClientModal';
import ActivitiesManager from './ActivitiesManager';
import OpportunityActivityChain from './OpportunityActivityChain';
// --- Types ---
@ -58,8 +59,18 @@ interface Opportunity {
specialRate?: number;
freeOffers?: string;
negotiationRemarks?: string;
createdAt?: string;
updatedAt?: string;
activities?: any[];
selectedSchemeId?: string;
addonsSelected?: string;
discountValue?: number;
isAmcMarked?: boolean;
amcPercentage?: number;
extraCharges?: number;
}
interface ColumnProps {
id: string;
title: string;
@ -160,10 +171,39 @@ const SortableItem = ({ opportunity, onEdit }: { opportunity: Opportunity; onEdi
</div>
</div>
<div className="text-[14px] font-bold text-gray-900 mb-2">
<div className="text-[14px] font-bold text-gray-900 mb-1">
{opportunity.value.toLocaleString()}
</div>
{(() => {
const formatDate = (dateStr?: string) => {
if (!dateStr) return 'N/A';
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return 'N/A';
return date.toLocaleDateString('en-IN', {
day: '2-digit',
month: 'short',
year: '2-digit'
});
} catch {
return 'N/A';
}
};
return (
<div className="flex items-center justify-between text-[9px] text-gray-400 border-t border-gray-100/50 pt-1 mt-1 mb-2">
<div>
<span className="font-bold uppercase tracking-wider text-[8px] text-gray-400 mr-1">Created:</span>
<span className="font-semibold text-gray-500">{formatDate(opportunity.createdAt)}</span>
</div>
<div className="text-right">
<span className="font-bold uppercase tracking-wider text-[8px] text-gray-400 mr-1">Updated:</span>
<span className="font-semibold text-gray-500">{formatDate(opportunity.updatedAt)}</span>
</div>
</div>
);
})()}
<div className="flex items-center justify-between mt-auto">
<div className="flex items-center space-x-2">
{renderStars(opportunity.priority)}
@ -273,6 +313,42 @@ const Column = ({ id, title, items, totalValue, onAddClick, onEditClick, colorTh
);
};
// --- Stage Events list for filters ---
const FILTER_EVENTS = [
{ id: 'all', label: 'All Stage Events', types: [] as string[] },
{ id: 'CALL', label: 'Initial Call', types: ['CALL'] },
{ id: 'MESSAGE', label: 'WhatsApp Message', types: ['MESSAGE'] },
{ id: 'DEMO_SCHEDULED', label: 'Demo Scheduled', types: ['DEMO_SCHEDULED'] },
{ id: 'DEMO_COMPLETED', label: 'Demo Completed', types: ['DEMO_COMPLETED', 'DEMO'] },
{ id: 'SECOND_DEMO', label: 'Second Demo', types: ['SECOND_DEMO'] },
{ id: 'QUOTE_REQUEST', label: 'Quote Request', types: ['QUOTE_REQUEST'] },
{ id: 'QUOTE_SEND', label: 'Quote Sent', types: ['QUOTE_SEND', 'QUOTE'] },
{ id: 'SECOND_QUOTE', label: 'Second Quote', types: ['SECOND_QUOTE'] },
{ id: 'VISIT_SCHEDULED', label: 'Visit Scheduled', types: ['VISIT_SCHEDULED'] },
{ id: 'VISIT_COMPLETED', label: 'Visit Completed', types: ['VISIT_COMPLETED'] },
{ id: 'MANAGER_HELP', label: 'Manager Help', types: ['MANAGER_HELP'] },
{ id: 'PROBABILITY_UPDATE', label: 'Probability Sync', types: ['PROBABILITY_UPDATE'] },
{ id: 'TIMEFRAME_UPDATE', label: 'Timeframe Sync', types: ['TIMEFRAME_UPDATE'] },
{ id: 'NEGOTIATION', label: 'Negotiation', types: ['NEGOTIATION'] },
{ id: 'FOLLOWUP', label: 'Follow-up / Other', types: ['FOLLOWUP'] },
];
const getFileUrl = (url: string) => {
if (!url) 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}`;
};
// --- Main Board Component ---
export default function OpportunityBoard() {
const { user } = useAuth();
@ -280,20 +356,137 @@ export default function OpportunityBoard() {
const [clients, setClients] = useState<any[]>([]);
const [assignees, setAssignees] = useState<any[]>([]);
const [products, setProducts] = useState<any[]>([]);
const [activeUserDiscounts, setActiveUserDiscounts] = useState<any[]>([]);
const [loadingAssignees, setLoadingAssignees] = useState(false);
const [clientSearchTerm, setClientSearchTerm] = useState('');
const [timeframeFilter, setTimeframeFilter] = useState<string>('all');
const [customStartDate, setCustomStartDate] = useState<string>('');
const [customEndDate, setCustomEndDate] = useState<string>('');
const [selectedEventFilter, setSelectedEventFilter] = useState<string>('all');
const [selectedEventStatusFilter, setSelectedEventStatusFilter] = useState<string>('all');
const [dateFilterField, setDateFilterField] = useState<'expectedCloseDate' | 'createdAt'>('expectedCloseDate');
const [selectedStaffFilter, setSelectedStaffFilter] = useState<string>('all');
const filteredItems = items.filter(item => {
if (!clientSearchTerm) return true;
// 1. Search term filter
if (clientSearchTerm) {
const term = clientSearchTerm.toLowerCase();
const clientName = item.client?.name?.toLowerCase() || '';
const companyName = item.client?.companyName?.toLowerCase() || '';
const contactName = item.client?.contactName?.toLowerCase() || '';
const clientEmail = item.client?.email?.toLowerCase() || '';
return clientName.includes(term) ||
const matchesSearch = clientName.includes(term) ||
companyName.includes(term) ||
contactName.includes(term) ||
clientEmail.includes(term);
if (!matchesSearch) return false;
}
// 2. Timeframe filter — by expectedCloseDate or createdAt depending on dateFilterField
if (timeframeFilter !== 'all') {
const now = new Date();
let start: number | null = null;
let end: number | null = null;
if (timeframeFilter === 'today') {
start = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).getTime();
} else if (timeframeFilter === 'tomorrow') {
start = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime();
end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 23, 59, 59, 999).getTime();
} else if (timeframeFilter === 'this_week') {
const day = now.getDay();
const diff = now.getDate() - day + (day === 0 ? -6 : 1);
const startOfWeek = new Date(now.getFullYear(), now.getMonth(), diff);
startOfWeek.setHours(0, 0, 0, 0);
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
endOfWeek.setHours(23, 59, 59, 999);
start = startOfWeek.getTime();
end = endOfWeek.getTime();
} else if (timeframeFilter === 'this_month') {
start = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999).getTime();
} else if (timeframeFilter === 'quarter') {
const quarter = Math.floor(now.getMonth() / 3);
start = new Date(now.getFullYear(), quarter * 3, 1).getTime();
end = new Date(now.getFullYear(), (quarter + 1) * 3, 0, 23, 59, 59, 999).getTime();
} else if (timeframeFilter === '3_months') {
const s = new Date();
s.setMonth(s.getMonth() - 3);
start = s.getTime();
end = now.getTime();
} else if (timeframeFilter === 'custom') {
if (customStartDate) { const s = new Date(customStartDate); s.setHours(0,0,0,0); start = s.getTime(); }
if (customEndDate) { const e = new Date(customEndDate); e.setHours(23,59,59,999); end = e.getTime(); }
}
let matchesTimeframe = false;
if (dateFilterField === 'expectedCloseDate') {
// Filter by expected close date
const closeTime = item.expectedCloseDate ? new Date(item.expectedCloseDate).getTime() : null;
if (closeTime === null) {
// Opportunities with no close date only show under 'all'
matchesTimeframe = false;
} else if (start !== null && end !== null) {
matchesTimeframe = closeTime >= start && closeTime <= end;
} else if (start !== null) {
matchesTimeframe = closeTime >= start;
} else if (end !== null) {
matchesTimeframe = closeTime <= end;
} else {
matchesTimeframe = true;
}
} else {
// Filter by createdAt or updatedAt
const createdTime = item.createdAt ? new Date(item.createdAt).getTime() : null;
const updatedTime = item.updatedAt ? new Date(item.updatedAt).getTime() : null;
if (start !== null && end !== null) {
matchesTimeframe = (createdTime !== null && createdTime >= start && createdTime <= end)
|| (updatedTime !== null && updatedTime >= start && updatedTime <= end);
} else if (start !== null) {
matchesTimeframe = (createdTime !== null && createdTime >= start)
|| (updatedTime !== null && updatedTime >= start);
} else if (end !== null) {
matchesTimeframe = (createdTime !== null && createdTime <= end)
|| (updatedTime !== null && updatedTime <= end);
} else {
matchesTimeframe = true;
}
}
if (!matchesTimeframe) return false;
}
// 3. Staff filter
if (selectedStaffFilter !== 'all') {
if (item.assignedTo !== selectedStaffFilter) return false;
}
// 4. Stage / Event wise filter
if (selectedEventFilter !== 'all') {
const matchedFilter = FILTER_EVENTS.find(f => f.id === selectedEventFilter);
const actList = item.activities || [];
// Collect activities that match the selected event types
const matchingActs = actList.filter(act => matchedFilter?.types.includes(act.type));
if (selectedEventStatusFilter === 'completed') {
const hasDone = matchingActs.some(act => act.status === 'DONE');
if (!hasDone) return false;
} else if (selectedEventStatusFilter === 'scheduled') {
const hasPending = matchingActs.some(act => act.status === 'PENDING');
if (!hasPending) return false;
} else if (selectedEventStatusFilter === 'not_started') {
if (matchingActs.length > 0) return false;
} else {
// 'all': just needs to have at least one activity of this type (either DONE or PENDING)
if (matchingActs.length === 0) return false;
}
}
return true;
});
const getFileUrl = (url: string) => {
@ -345,14 +538,77 @@ export default function OpportunityBoard() {
closingOwnerId: '',
isDemoDone: false,
closingProbability: 0,
expectedClosingTimeframe: ''
expectedClosingTimeframe: '',
selectedSchemeId: '',
addonsSelected: '[]',
discountValue: 0,
isAmcMarked: false,
amcPercentage: 0,
extraCharges: 0
});
useEffect(() => {
const fetchActiveProductDiscounts = async () => {
const matchedProduct = products.find(p => p.name.toLowerCase() === newItemData.title.toLowerCase());
if (matchedProduct) {
try {
const { data } = await api.get(`/products/${matchedProduct.id}/user-discounts`);
setActiveUserDiscounts(data);
} catch (err) {
console.error("Failed to fetch user discounts", err);
setActiveUserDiscounts([]);
}
} else {
setActiveUserDiscounts([]);
}
};
fetchActiveProductDiscounts();
}, [newItemData.title, newItemData.assignedTo, products]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
useEffect(() => {
const selectedProduct = products.find(p => p.name.toLowerCase() === newItemData.title.toLowerCase());
if (!selectedProduct) return;
const activeScheme = selectedProduct.schemes?.find((s: any) => s.id === newItemData.selectedSchemeId);
const basePrice = activeScheme ? Number(activeScheme.price) : Number(selectedProduct.price || 0);
let selectedAddons = [];
try {
selectedAddons = JSON.parse(newItemData.addonsSelected || '[]');
} catch (e) {
selectedAddons = [];
}
const addonsSum = selectedAddons.reduce((sum: number, addon: any) => sum + Number(addon.price || 0), 0);
const subtotal = basePrice + addonsSum;
const discount = Number(newItemData.discountValue || 0);
const extra = Number(newItemData.extraCharges || 0);
const amcPrice = newItemData.isAmcMarked
? ((subtotal - discount) * (Number(newItemData.amcPercentage || 0) / 100))
: 0;
const calculatedTotal = Math.max(0, subtotal - discount + extra + amcPrice);
const totalStr = String(Math.round(calculatedTotal));
if (newItemData.value !== totalStr) {
setNewItemData(prev => ({ ...prev, value: totalStr }));
}
}, [
newItemData.title,
newItemData.selectedSchemeId,
newItemData.addonsSelected,
newItemData.discountValue,
newItemData.isAmcMarked,
newItemData.amcPercentage,
newItemData.extraCharges,
products
]);
useEffect(() => {
fetchOpportunities();
fetchClients();
@ -388,6 +644,49 @@ export default function OpportunityBoard() {
} catch (err) { console.error(err) }
}
const getDiscountLimitInfo = () => {
const matchedProduct = products.find(p => p.name.toLowerCase() === newItemData.title.toLowerCase());
if (!matchedProduct) return null;
const targetUserId = newItemData.assignedTo || user?.id;
if (!targetUserId) return null;
const activeScheme = matchedProduct.schemes?.find((s: any) => s.id === newItemData.selectedSchemeId);
const basePrice = activeScheme ? Number(activeScheme.price) : Number(matchedProduct.price || 0);
let allowedPercentage = 100;
let capSource = 'Default';
const schemeId = activeScheme?.id;
const userSchemeCap = schemeId ? activeUserDiscounts.find((d: any) => d.userId === targetUserId && d.schemeId === schemeId) : null;
const userProductCap = activeUserDiscounts.find((d: any) => d.userId === targetUserId && !d.schemeId);
if (userSchemeCap) {
allowedPercentage = userSchemeCap.maxDiscountPercentage;
capSource = 'User-Scheme Override';
} else if (userProductCap) {
allowedPercentage = userProductCap.maxDiscountPercentage;
capSource = 'User-Product Override';
} else if (activeScheme && activeScheme.maxDiscountPercentage !== null && activeScheme.maxDiscountPercentage !== undefined) {
allowedPercentage = activeScheme.maxDiscountPercentage;
capSource = 'Scheme Default';
} else if (matchedProduct.maxDiscountPercentage !== null && matchedProduct.maxDiscountPercentage !== undefined) {
allowedPercentage = matchedProduct.maxDiscountPercentage;
capSource = 'Product Default';
}
const maxAllowedValue = (basePrice * allowedPercentage) / 100;
const appliedDiscount = Number(newItemData.discountValue || 0);
const isExceeded = appliedDiscount > maxAllowedValue + 0.01;
return {
allowedPercentage,
maxAllowedValue,
isExceeded,
capSource
};
}
const fetchAssignees = async () => {
setLoadingAssignees(true);
try {
@ -424,7 +723,13 @@ export default function OpportunityBoard() {
closingOwnerId: '',
isDemoDone: false,
closingProbability: 0,
expectedClosingTimeframe: ''
expectedClosingTimeframe: '',
selectedSchemeId: '',
addonsSelected: '[]',
discountValue: 0,
isAmcMarked: false,
amcPercentage: 0,
extraCharges: 0
});
setIsModalOpen(true);
};
@ -470,7 +775,13 @@ export default function OpportunityBoard() {
closingOwnerId: (item as any).closingOwnerId || '',
isDemoDone: !!item.isDemoDone,
closingProbability: (item as any).closingProbability || 0,
expectedClosingTimeframe: (item as any).expectedClosingTimeframe || ''
expectedClosingTimeframe: (item as any).expectedClosingTimeframe || '',
selectedSchemeId: item.selectedSchemeId || '',
addonsSelected: item.addonsSelected || '[]',
discountValue: item.discountValue || 0,
isAmcMarked: !!item.isAmcMarked,
amcPercentage: item.amcPercentage || 0,
extraCharges: item.extraCharges || 0
});
setActiveModalTab('details');
setIsModalOpen(true);
@ -482,7 +793,11 @@ export default function OpportunityBoard() {
const payload = {
...newItemData,
value: Number(newItemData.value),
specialRate: newItemData.specialRate ? Number(newItemData.specialRate) : undefined
specialRate: newItemData.specialRate ? Number(newItemData.specialRate) : undefined,
discountValue: Number(newItemData.discountValue || 0),
amcPercentage: Number(newItemData.amcPercentage || 0),
extraCharges: Number(newItemData.extraCharges || 0),
isAmcMarked: !!newItemData.isAmcMarked
};
if (editingId) {
@ -572,7 +887,13 @@ export default function OpportunityBoard() {
closingOwnerId: (activeItem as any).closingOwnerId || '',
isDemoDone: !!activeItem.isDemoDone,
closingProbability: (activeItem as any).closingProbability || 0,
expectedClosingTimeframe: (activeItem as any).expectedClosingTimeframe || ''
expectedClosingTimeframe: (activeItem as any).expectedClosingTimeframe || '',
selectedSchemeId: (activeItem as any).selectedSchemeId || '',
addonsSelected: (activeItem as any).addonsSelected || '[]',
discountValue: (activeItem as any).discountValue || 0,
isAmcMarked: !!(activeItem as any).isAmcMarked,
amcPercentage: (activeItem as any).amcPercentage || 0,
extraCharges: (activeItem as any).extraCharges || 0
});
setIsModalOpen(true);
} else {
@ -635,6 +956,154 @@ export default function OpportunityBoard() {
</div>
</div>
{/* Filters Bar */}
<div className="flex flex-wrap items-center justify-between px-4 py-2.5 bg-slate-50 border-b border-gray-200 gap-2">
<div className="flex flex-wrap items-center gap-3">
{/* Date Field Toggle */}
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-black text-gray-400 uppercase tracking-wider">Filter By:</span>
<div className="flex items-center bg-gray-200/60 p-0.5 rounded-lg border border-gray-200">
<button
type="button"
onClick={() => setDateFilterField('expectedCloseDate')}
className={`px-2.5 py-1 text-[10px] font-bold rounded transition-all ${
dateFilterField === 'expectedCloseDate'
? 'bg-white text-odoo-primary shadow-sm font-black'
: 'text-gray-500 hover:text-gray-900'
}`}
>
Close Date
</button>
<button
type="button"
onClick={() => setDateFilterField('createdAt')}
className={`px-2.5 py-1 text-[10px] font-bold rounded transition-all ${
dateFilterField === 'createdAt'
? 'bg-white text-odoo-primary shadow-sm font-black'
: 'text-gray-500 hover:text-gray-900'
}`}
>
Created Date
</button>
</div>
</div>
{/* Timeframe Buttons */}
<div className="flex items-center gap-1">
<span className="text-[10px] font-black text-gray-400 uppercase tracking-wider">Period:</span>
<div className="flex items-center bg-gray-200/50 p-0.5 rounded-lg border border-gray-200">
{[
{ id: 'all', label: 'All' },
{ id: 'today', label: 'Today' },
{ id: 'tomorrow', label: 'Tomorrow' },
{ id: 'this_week', label: 'This Week' },
{ id: 'this_month', label: 'This Month' },
{ id: 'quarter', label: 'Quarter' },
{ id: '3_months', label: 'Last 3 Mo.' },
{ id: 'custom', label: 'Custom' }
].map((btn) => (
<button
key={btn.id}
type="button"
onClick={() => setTimeframeFilter(btn.id)}
className={`px-2.5 py-1 text-[11px] font-bold rounded transition-all ${
timeframeFilter === btn.id
? 'bg-white text-odoo-primary shadow-sm font-black'
: 'text-gray-500 hover:text-gray-900 hover:bg-white/30'
}`}
>
{btn.label}
</button>
))}
</div>
</div>
</div>
{timeframeFilter === 'custom' && (
<div className="flex items-center space-x-2 animate-in slide-in-from-left-2 duration-200 bg-white border border-gray-200 p-1 px-2.5 rounded-lg">
<span className="text-[10px] font-black text-gray-400 uppercase">From</span>
<input
type="date"
className="bg-transparent text-xs font-bold text-gray-700 outline-none cursor-pointer"
value={customStartDate}
onChange={(e) => setCustomStartDate(e.target.value)}
/>
<span className="text-[10px] font-black text-gray-400 uppercase">To</span>
<input
type="date"
className="bg-transparent text-xs font-bold text-gray-700 outline-none cursor-pointer"
value={customEndDate}
onChange={(e) => setCustomEndDate(e.target.value)}
/>
</div>
)}
{/* Right-side Filters: Staff + Stage Event */}
<div className="flex items-center gap-2 flex-wrap">
{/* Staff Filter */}
<div className="flex items-center gap-1.5 bg-white border border-gray-200 px-3 py-1 rounded-lg shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" className="w-3 h-3 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>
<span className="text-[10px] font-black text-gray-400 uppercase">Staff:</span>
<select
value={selectedStaffFilter}
onChange={(e) => setSelectedStaffFilter(e.target.value)}
className="bg-transparent text-xs font-bold text-gray-700 outline-none cursor-pointer py-0.5 max-w-[140px]"
>
<option value="all">All Staff</option>
{assignees.map((u: any) => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
{selectedStaffFilter !== 'all' && (
<button
type="button"
onClick={() => setSelectedStaffFilter('all')}
className="ml-1 text-gray-400 hover:text-red-500 transition-colors"
title="Clear staff filter"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
)}
</div>
{/* Stage Event Filter */}
<div className="flex items-center space-x-1.5 bg-white border border-gray-200 px-3 py-1 rounded-lg shadow-sm">
<span className="text-[10px] font-black text-gray-400 uppercase">Stage Event:</span>
<select
value={selectedEventFilter}
onChange={(e) => {
setSelectedEventFilter(e.target.value);
if (e.target.value === 'all') {
setSelectedEventStatusFilter('all');
}
}}
className="bg-transparent text-xs font-bold text-gray-700 outline-none cursor-pointer py-0.5"
>
{FILTER_EVENTS.map(f => (
<option key={f.id} value={f.id}>{f.label}</option>
))}
</select>
</div>
{selectedEventFilter !== 'all' && (
<div className="flex items-center space-x-1.5 bg-white border border-gray-200 px-3 py-1 rounded-lg shadow-sm animate-in slide-in-from-left-2 duration-200">
<span className="text-[10px] font-black text-gray-400 uppercase">Status:</span>
<select
value={selectedEventStatusFilter}
onChange={(e) => setSelectedEventStatusFilter(e.target.value)}
className="bg-transparent text-xs font-bold text-gray-700 outline-none cursor-pointer py-0.5"
>
<option value="all">Any Status</option>
<option value="completed">Completed / Done</option>
<option value="scheduled">Scheduled / Pending</option>
<option value="not_started">Not Started</option>
</select>
</div>
)}
</div>
</div>
<div className="flex-1 overflow-x-auto bg-[#f8f9fa] p-4">
<DndContext
sensors={sensors}
@ -672,13 +1141,16 @@ export default function OpportunityBoard() {
<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);
if (e.target === e.currentTarget) {
setIsModalOpen(false);
fetchOpportunities();
}
}}
>
<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">
<h3 className="font-bold text-[18px] text-gray-800">{editingId ? 'Edit Deal' : 'New Deal'}</h3>
<button onClick={() => setIsModalOpen(false)} className="p-1 rounded hover:bg-gray-200 text-gray-400 transition-colors">
<button onClick={() => { setIsModalOpen(false); fetchOpportunities(); }} className="p-1 rounded hover:bg-gray-200 text-gray-400 transition-colors">
<X size={20} />
</button>
</div>
@ -716,11 +1188,24 @@ export default function OpportunityBoard() {
onChange={e => {
const val = e.target.value;
const matchedProduct = products.find(p => p.name.toLowerCase() === val.toLowerCase());
if (matchedProduct) {
setNewItemData({
...newItemData,
title: val,
value: matchedProduct ? String(matchedProduct.price) : newItemData.value
value: String(matchedProduct.price),
selectedSchemeId: 'base',
addonsSelected: '[]',
discountValue: 0,
isAmcMarked: false,
amcPercentage: 15,
extraCharges: 0
});
} else {
setNewItemData({
...newItemData,
title: val
});
}
}}
placeholder="e.g. Website Redesign"
/>
@ -749,18 +1234,288 @@ export default function OpportunityBoard() {
)}
</div>
{/* Unified Pricing Configurator Block */}
<div className="col-span-2">
{(() => {
const selectedProduct = products.find(p => p.name.toLowerCase() === newItemData.title.toLowerCase());
if (selectedProduct) {
let activeAddons: any[] = [];
try {
activeAddons = JSON.parse(newItemData.addonsSelected || '[]');
} catch {
activeAddons = [];
}
const activeScheme = selectedProduct.schemes?.find((s: any) => s.id === newItemData.selectedSchemeId);
const basePrice = activeScheme ? Number(activeScheme.price) : Number(selectedProduct.price || 0);
const addonsSum = activeAddons.reduce((sum, a) => sum + Number(a.price || 0), 0);
const discountValue = Number(newItemData.discountValue || 0);
const extraCharges = Number(newItemData.extraCharges || 0);
const limitInfo = getDiscountLimitInfo();
const subtotal = basePrice + addonsSum;
const amcValue = newItemData.isAmcMarked
? ((subtotal - discountValue) * (Number(newItemData.amcPercentage || 0) / 100))
: 0;
const totalValue = Math.max(0, subtotal - discountValue + extraCharges + amcValue);
return (
<div className="bg-slate-50 border border-slate-200/80 p-5 rounded-2xl space-y-4 animate-in fade-in duration-200 shadow-sm col-span-2">
<div className="flex items-center gap-2 border-b border-slate-200 pb-2">
<TrendingUp className="text-odoo-primary w-4.5 h-4.5" />
<h4 className="text-[12px] font-black text-slate-700 uppercase tracking-widest">Pricing Configurator</h4>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Base Scheme Selector */}
<div className="col-span-2 sm:col-span-1">
<label className="block text-[10px] font-black text-slate-500 uppercase tracking-widest mb-1.5">Base Price Scheme</label>
<select
className="w-full border border-slate-200 rounded-lg p-2 bg-white focus:border-odoo-primary focus:ring-1 focus:ring-odoo-primary outline-none text-xs font-bold text-slate-700 cursor-pointer"
value={newItemData.selectedSchemeId || 'base'}
onChange={e => {
setNewItemData(prev => ({ ...prev, selectedSchemeId: e.target.value }));
}}
>
<option value="base">
Standard Base {Number(selectedProduct.price).toLocaleString()}
</option>
{selectedProduct.schemes?.map((sch: any) => (
<option key={sch.id} value={sch.id}>
{sch.name} {Number(sch.price).toLocaleString()}
</option>
))}
</select>
</div>
{/* AMC Configuration */}
<div className="col-span-2 sm:col-span-1 bg-white p-2.5 rounded-xl border border-slate-200/60 flex flex-col justify-center">
<div className="flex items-center gap-3">
<input
type="checkbox"
id="isAmcMarked"
className="w-4 h-4 text-odoo-primary rounded focus:ring-odoo-primary cursor-pointer border-slate-300"
checked={!!newItemData.isAmcMarked}
onChange={e => setNewItemData(prev => ({
...prev,
isAmcMarked: e.target.checked,
amcPercentage: e.target.checked ? (prev.amcPercentage || 15) : 0
}))}
/>
<div>
<label htmlFor="isAmcMarked" className="text-xs font-bold text-slate-700 cursor-pointer block">Annual Maintenance (AMC)</label>
<span className="text-[10px] text-slate-400 font-medium">Add contract terms</span>
</div>
</div>
{newItemData.isAmcMarked && (
<div className="mt-2 flex items-center gap-2 animate-in slide-in-from-top-1 duration-200">
<span className="text-[10px] font-bold text-slate-500">AMC Percentage:</span>
<input
type="number"
min="0"
max="100"
className="w-14 border border-slate-200 rounded px-1.5 py-0.5 text-xs font-bold text-slate-700 outline-none focus:border-odoo-primary text-center"
value={newItemData.amcPercentage}
onChange={e => setNewItemData(prev => ({ ...prev, amcPercentage: Number(e.target.value) }))}
/>
<span className="text-xs font-bold text-slate-500">%</span>
</div>
)}
</div>
{/* Brochure Information Bar */}
{(selectedProduct.brochureUrl || activeScheme?.brochureUrl) && (
<div className="col-span-2 flex items-center gap-3 bg-indigo-50/50 p-3 rounded-xl border border-indigo-100/40 flex-wrap animate-in fade-in duration-200">
<span className="text-[9px] font-black text-indigo-500 uppercase tracking-widest block w-full">Sales Brochure Downloads:</span>
{selectedProduct.brochureUrl && (
<a
href={getFileUrl(selectedProduct.brochureUrl)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs font-bold text-indigo-700 hover:text-indigo-900 hover:underline bg-white border border-indigo-200 px-2.5 py-1.5 rounded-lg shadow-sm"
>
<FileText size={13} className="text-indigo-600" />
Product Brochure
</a>
)}
{activeScheme?.brochureUrl && (
<a
href={getFileUrl(activeScheme.brochureUrl)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs font-bold text-indigo-700 hover:text-indigo-900 hover:underline bg-white border border-indigo-200 px-2.5 py-1.5 rounded-lg shadow-sm"
>
<FileText size={13} className="text-indigo-600" />
Scheme Brochure ({activeScheme.name})
</a>
)}
</div>
)}
{/* Addons Checklist */}
<div className="col-span-2 border-t border-slate-200/60 pt-3">
<label className="block text-[10px] font-black text-indigo-600 uppercase tracking-widest mb-2">Available Addons</label>
{selectedProduct.addons && selectedProduct.addons.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{selectedProduct.addons.map((addon: any) => {
const isChecked = activeAddons.some((a: any) => a.id === addon.id);
return (
<div
key={addon.id}
onClick={() => {
let newAddons = [...activeAddons];
if (isChecked) {
newAddons = newAddons.filter((a: any) => a.id !== addon.id);
} else {
newAddons.push(addon);
}
setNewItemData(prev => ({ ...prev, addonsSelected: JSON.stringify(newAddons) }));
}}
className={`flex items-start gap-2.5 p-2 rounded-lg border cursor-pointer transition-all ${
isChecked
? 'bg-indigo-50/50 border-indigo-200 shadow-sm'
: 'bg-white border-slate-200/60 hover:bg-slate-100/50'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => {}}
className="mt-0.5 w-3.5 h-3.5 text-indigo-600 rounded focus:ring-indigo-500 cursor-pointer border-slate-300"
/>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center gap-1.5">
<span className="text-xs font-bold text-slate-700 truncate">{addon.name}</span>
<span className="text-xs font-black text-indigo-600 shrink-0">{addon.price.toLocaleString()}</span>
</div>
{addon.description && (
<p className="text-[10px] text-slate-400 truncate mt-0.5">{addon.description}</p>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-2.5 bg-white border border-dashed border-slate-200 rounded-lg">
<span className="text-[11px] text-slate-400 font-bold">No product addons registered.</span>
</div>
)}
</div>
{/* Adjustments: Discount & Extra Charges */}
<div className="col-span-2 grid grid-cols-2 gap-4 bg-white p-3 rounded-xl border border-slate-200/60">
<div>
<label className="block text-[9px] font-black text-slate-400 uppercase tracking-wider mb-1">Apply Discount ()</label>
<div className="flex items-center space-x-1.5">
<span className="text-xs text-slate-400 font-bold"></span>
<input
type="number"
min="0"
className={`w-full border-b py-0.5 text-xs font-bold outline-none focus:border-odoo-primary ${
limitInfo?.isExceeded
? 'border-rose-500 text-rose-600 focus:border-rose-500'
: 'border-slate-200 text-slate-700'
}`}
value={newItemData.discountValue || ''}
onChange={e => setNewItemData(prev => ({ ...prev, discountValue: Number(e.target.value) }))}
placeholder="0"
/>
</div>
{limitInfo && (
<span className={`text-[10px] block mt-1 font-semibold ${
limitInfo.isExceeded ? 'text-rose-500 font-bold' : 'text-slate-400'
}`}>
Max Cap: {limitInfo.maxAllowedValue.toLocaleString()} ({limitInfo.allowedPercentage}%)
</span>
)}
</div>
<div>
<label className="block text-[9px] font-black text-slate-400 uppercase tracking-wider mb-1">Extra Charges ()</label>
<div className="flex items-center space-x-1.5">
<span className="text-xs text-slate-400 font-bold"></span>
<input
type="number"
min="0"
className="w-full border-b border-slate-200 py-0.5 text-xs font-bold text-slate-700 outline-none focus:border-odoo-primary"
value={newItemData.extraCharges || ''}
onChange={e => setNewItemData(prev => ({ ...prev, extraCharges: Number(e.target.value) }))}
placeholder="0"
/>
</div>
</div>
</div>
{/* Price Breakdown Summary */}
<div className="col-span-2 bg-gradient-to-br from-slate-900 to-slate-800 text-white rounded-xl p-4 shadow-sm space-y-2">
<div className="flex justify-between items-center border-b border-white/10 pb-1.5">
<span className="text-[10px] font-black uppercase tracking-wider text-slate-300">pricing calculation</span>
<span className="text-[9px] font-black text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/20 uppercase tracking-widest">live</span>
</div>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between text-slate-300">
<span>Base MRP {activeScheme ? `(${activeScheme.name})` : '(Standard)'}</span>
<span className="font-semibold">{basePrice.toLocaleString()}</span>
</div>
{activeAddons.length > 0 && (
<div className="flex justify-between text-indigo-300">
<span>Addons ({activeAddons.length})</span>
<span className="font-semibold">+{addonsSum.toLocaleString()}</span>
</div>
)}
{discountValue > 0 && (
<div className="flex justify-between text-rose-400">
<span>Discount</span>
<span className="font-semibold">-{discountValue.toLocaleString()}</span>
</div>
)}
{extraCharges > 0 && (
<div className="flex justify-between text-emerald-400">
<span>Extra Charges</span>
<span className="font-semibold">+{extraCharges.toLocaleString()}</span>
</div>
)}
{newItemData.isAmcMarked && amcValue > 0 && (
<div className="flex justify-between text-amber-300">
<span>AMC Support Contract ({newItemData.amcPercentage}%)</span>
<span className="font-semibold">+{Math.round(amcValue).toLocaleString()}</span>
</div>
)}
</div>
<div className="flex justify-between items-center border-t border-white/10 pt-2 mt-1">
<span className="text-xs font-bold text-slate-300">Calculated Expected Revenue</span>
<span className="text-[16px] font-black text-emerald-400">{Math.round(totalValue).toLocaleString()}</span>
</div>
</div>
</div>
</div>
);
}
// Fallback if not matched with catalog product
return (
<div className="bg-slate-50 border border-slate-200 p-4 rounded-xl space-y-2 col-span-2">
<label className="block text-[13px] font-bold text-gray-500 mb-1">Expected Revenue ()</label>
<div className="flex items-center space-x-2">
<span className="text-gray-400 font-bold"></span>
<input
type="number"
required
className="w-full border-b border-gray-200 py-1 focus:border-odoo-primary outline-none"
className="w-full border-b border-gray-200 py-1 focus:border-odoo-primary outline-none font-semibold text-gray-700 bg-white px-2 rounded"
value={newItemData.value}
onChange={e => setNewItemData({ ...newItemData, value: e.target.value })}
/>
</div>
<p className="text-[10px] text-gray-400">No matching catalog product. Enter pricing directly or select a product name from the title list to unlock the advanced pricing configurator.</p>
</div>
);
})()}
</div>
<div>
@ -1020,7 +1775,7 @@ export default function OpportunityBoard() {
</button>
<button
type="button"
onClick={() => setIsModalOpen(false)}
onClick={() => { setIsModalOpen(false); fetchOpportunities(); }}
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
@ -1029,7 +1784,7 @@ export default function OpportunityBoard() {
</form>
) : (
<div className="flex-1 overflow-hidden h-[600px] flex flex-col p-4 bg-gray-50">
<ActivitiesManager initialClientId={newItemData.clientId} initialOpportunityId={editingId || undefined} />
<OpportunityActivityChain opportunityId={editingId || ''} clientId={newItemData.clientId} onActivityUpdated={fetchOpportunities} />
</div>
)}
</div>

View File

@ -0,0 +1,950 @@
'use client';
import React, { useEffect, useState, useMemo } from 'react';
import api from '../lib/axios';
import {
Calendar, Clock, AlertTriangle, Presentation, FileText, CheckCircle2,
Search, X, MapPin, ClipboardCheck, Handshake, User, Users,
TrendingUp, HelpCircle, CalendarClock, RefreshCw, Edit3
} from 'lucide-react';
import { format, startOfDay, endOfDay, startOfWeek, endOfWeek, startOfMonth, endOfMonth } from 'date-fns';
import { useAuth } from '@/context/AuthContext';
import ActivitiesManager from './ActivitiesManager';
interface Activity {
id: string;
type: string;
notes: string;
status: string;
date: string;
createdAt: string;
client?: { id: string; name: string; companyName?: string };
user?: { id: string; name: string };
opportunity?: { id: string; title: string };
stage?: string;
}
const FEEDBACK_TYPES = ['DEMO', 'DEMO_SCHEDULED', 'DEMO_COMPLETED', 'VISIT_SCHEDULED', 'VISIT_COMPLETED'];
const defaultFeedback = {
remarks: '',
demoPersonName: '',
demoContactDetails: '',
customerFeedback: '',
requirementDetails: '',
suggestions: '',
budget: '',
expectedClosingTimeline: '',
competitorInfo: '',
staffRemarks: '',
customerCommitments: '',
caCsDetails: '',
};
export default function PipelineActivityEngine() {
const { user } = useAuth();
const [activities, setActivities] = useState<Activity[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
const [showNewActivityModal, setShowNewActivityModal] = useState(false);
const [activeTab, setActiveTab] = useState<'done' | 'reschedule' | 'edit'>('done');
const [feedback, setFeedback] = useState({ ...defaultFeedback });
const [reschedule, setReschedule] = useState({ date: '', time: '10:00', reason: '' });
const [editDetails, setEditDetails] = useState({ userId: '', notes: '' });
const [users, setUsers] = useState<{ id: string; name: string }[]>([]);
const [submitting, setSubmitting] = useState(false);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const [dateFilter, setDateFilter] = useState<{ preset: string; from: string; to: string }>({
preset: 'all', from: '', to: '',
});
const [assigneeFilter, setAssigneeFilter] = useState<string>('all');
const [categoryFilter, setCategoryFilter] = useState<string>('today');
const [inlineRemarks, setInlineRemarks] = useState<Record<string, string>>({});
useEffect(() => {
fetchActivities();
api.get('/users').then(r => setUsers(r.data)).catch(() => {});
}, []);
const fetchActivities = async () => {
try {
setLoading(true);
const res = await api.get('/followups');
setActivities(res.data);
} catch (e) {
console.error('Failed to load activities', e);
} finally {
setLoading(false);
}
};
const categorizedActivities = useMemo(() => {
const today = new Date();
const startOfToday = startOfDay(today);
const endOfToday = endOfDay(today);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const startOfTomorrow = startOfDay(tomorrow);
const endOfTomorrow = endOfDay(tomorrow);
const currentWeekEnd = endOfWeek(today, { weekStartsOn: 1 });
// Apply date range filter
let filterFrom: Date | null = null;
let filterTo: Date | null = null;
if (dateFilter.preset === 'today') {
filterFrom = startOfToday;
filterTo = endOfToday;
} else if (dateFilter.preset === 'tomorrow') {
filterFrom = startOfTomorrow;
filterTo = endOfTomorrow;
} else if (dateFilter.preset === 'week') {
filterFrom = startOfWeek(today, { weekStartsOn: 1 });
filterTo = endOfWeek(today, { weekStartsOn: 1 });
} else if (dateFilter.preset === 'month') {
filterFrom = startOfMonth(today);
filterTo = endOfMonth(today);
} else if (dateFilter.preset === 'custom') {
if (dateFilter.from) filterFrom = startOfDay(new Date(dateFilter.from));
if (dateFilter.to) filterTo = endOfDay(new Date(dateFilter.to));
}
const filtered = activities.filter(a => {
const searchStr = `${a.client?.companyName} ${a.notes} ${a.type}`.toLowerCase();
const matchesSearch = searchStr.includes(searchTerm.toLowerCase());
const actDate = new Date(a.date);
const matchesDate = (!filterFrom || actDate >= filterFrom) && (!filterTo || actDate <= filterTo);
const matchesAssignee = assigneeFilter === 'all' || a.user?.id === assigneeFilter;
return matchesSearch && matchesDate && matchesAssignee;
});
const pending = filtered.filter(a => a.status !== 'DONE' && a.status !== 'EXPIRED');
return {
expired: pending.filter(a => new Date(a.date) < startOfToday),
today: pending.filter(a => {
const d = new Date(a.date);
return d >= startOfToday && d <= endOfToday;
}),
tomorrow: pending.filter(a => {
const d = new Date(a.date);
return d >= startOfTomorrow && d <= endOfTomorrow;
}),
thisWeek: pending.filter(a => {
const d = new Date(a.date);
return d > endOfTomorrow && d <= currentWeekEnd;
}),
upcoming: pending.filter(a => new Date(a.date) > currentWeekEnd),
};
}, [activities, searchTerm, dateFilter, assigneeFilter]);
const getTypeIcon = (type: string) => {
switch (type) {
case 'DEMO': case 'SECOND_DEMO': case 'DEMO_SCHEDULED': case 'DEMO_COMPLETED':
return <Presentation size={18} className="text-blue-500" />;
case 'QUOTE': case 'SECOND_QUOTE': case 'QUOTE_SEND': case 'QUOTE_REQUEST':
return <FileText size={18} className="text-purple-500" />;
case 'MANAGER_HELP':
return <HelpCircle size={18} className="text-rose-500" />;
case 'TIMEFRAME_UPDATE':
return <Calendar size={18} className="text-amber-500" />;
case 'PROBABILITY_UPDATE':
return <TrendingUp size={18} className="text-indigo-500" />;
case 'VISIT_SCHEDULED': case 'VISIT_COMPLETED':
return <MapPin size={18} className="text-orange-500" />;
case 'NEGOTIATION':
return <Handshake size={18} className="text-teal-500" />;
default:
return <Calendar size={18} className="text-gray-500" />;
}
};
const formatTypeLabel = (type: string) => type.replace(/_/g, ' ');
const needsFeedback = (type: string) => FEEDBACK_TYPES.includes(type);
const handleActionClick = (activity: Activity) => {
setSelectedActivity(activity);
setActiveTab('done');
setFeedback({ ...defaultFeedback });
setReschedule({ date: '', time: '10:00', reason: '' });
setEditDetails({ userId: activity.user?.id || '', notes: activity.notes || '' });
};
const handleInlineMarkDone = async (activity: Activity) => {
if (needsFeedback(activity.type)) {
handleActionClick(activity);
return;
}
setSubmitting(true);
try {
const remarks = inlineRemarks[activity.id];
const payload: Record<string, string> = { status: 'DONE' };
if (remarks) payload.notes = remarks;
await api.patch(`/followups/${activity.id}`, payload);
setSuccessMsg('✅ Activity marked as Done!');
setTimeout(() => setSuccessMsg(null), 3000);
setInlineRemarks(prev => {
const updated = { ...prev };
delete updated[activity.id];
return updated;
});
fetchActivities();
} catch (e) {
alert('Failed to mark activity as done.');
} finally {
setSubmitting(false);
}
};
const handleMarkDone = async () => {
if (!selectedActivity) return;
if (needsFeedback(selectedActivity.type)) {
const requiredFields: (keyof typeof feedback)[] = [
'customerFeedback', 'requirementDetails', 'suggestions', 'budget',
'expectedClosingTimeline', 'competitorInfo', 'staffRemarks',
'customerCommitments', 'caCsDetails',
];
const missing = requiredFields.filter(f => !feedback[f]);
if (missing.length > 0) {
alert(`Please fill all mandatory fields:\n${missing.map(f => f.replace(/([A-Z])/g, ' $1')).join(', ')}`);
return;
}
}
setSubmitting(true);
try {
const payload: Record<string, string> = { status: 'DONE' };
if (feedback.remarks) payload.notes = feedback.remarks;
if (needsFeedback(selectedActivity.type)) {
Object.assign(payload, {
demoPersonName: feedback.demoPersonName,
demoContactDetails: feedback.demoContactDetails,
customerFeedback: feedback.customerFeedback,
requirementDetails: feedback.requirementDetails,
suggestions: feedback.suggestions,
budget: feedback.budget,
expectedClosingTimeline: feedback.expectedClosingTimeline,
competitorInfo: feedback.competitorInfo,
staffRemarks: feedback.staffRemarks,
customerCommitments: feedback.customerCommitments,
caCsDetails: feedback.caCsDetails,
});
}
await api.patch(`/followups/${selectedActivity.id}`, payload);
setSuccessMsg('✅ Activity marked as Done!');
setTimeout(() => setSuccessMsg(null), 3000);
setSelectedActivity(null);
fetchActivities();
} catch (e) {
alert('Failed to mark activity as done.');
} finally {
setSubmitting(false);
}
};
const handleReschedule = async () => {
if (!selectedActivity) return;
if (!reschedule.date) {
alert('Please select a new date.');
return;
}
setSubmitting(true);
try {
const newDate = new Date(`${reschedule.date}T${reschedule.time}:00`).toISOString();
await api.patch(`/followups/${selectedActivity.id}`, {
date: newDate,
status: 'PENDING',
...(reschedule.reason ? { notes: reschedule.reason } : {}),
});
setSuccessMsg('📅 Activity rescheduled successfully!');
setTimeout(() => setSuccessMsg(null), 3000);
setSelectedActivity(null);
fetchActivities();
} catch (e) {
alert('Failed to reschedule activity.');
} finally {
setSubmitting(false);
}
};
const handleEditDetails = async () => {
if (!selectedActivity) return;
setSubmitting(true);
try {
await api.patch(`/followups/${selectedActivity.id}`, {
...(editDetails.userId ? { userId: editDetails.userId } : {}),
notes: editDetails.notes,
});
setSuccessMsg('✏️ Activity updated successfully!');
setTimeout(() => setSuccessMsg(null), 3000);
setSelectedActivity(null);
fetchActivities();
} catch (e) {
alert('Failed to update activity.');
} finally {
setSubmitting(false);
}
};
const ActivityTableRow = ({ a, colorClass }: { a: Activity, colorClass: string }) => (
<tr className={`border-b border-gray-100 ${colorClass} transition-colors group`}>
<td className="px-4 py-3 whitespace-nowrap border-r border-gray-100/50 align-top">
<div className="font-black text-gray-800">
{format(new Date(a.date), 'dd-MM-yyyy')}
</div>
<div className="text-[10px] opacity-70 font-bold uppercase mt-0.5 text-gray-800">
{format(new Date(a.date), 'hh:mm a')}
</div>
</td>
<td className="px-4 py-3 border-r border-gray-100/50 align-top min-w-[200px]">
<div className="flex items-center gap-2 mb-1">
<div className="flex items-center gap-1.5 font-bold text-gray-800">
<span className="opacity-70">{getTypeIcon(a.type)}</span>
{formatTypeLabel(a.type)}
</div>
</div>
{a.client && <div className="text-[10px] font-bold text-gray-700 opacity-80 truncate">🏢 {a.client.companyName || a.client.name}</div>}
{a.opportunity && <div className="text-[10px] font-semibold text-gray-600 opacity-70 truncate mt-0.5">💼 {a.opportunity.title}</div>}
{a.notes && <p className="text-xs text-gray-600 italic mt-1.5 line-clamp-2">"{a.notes}"</p>}
</td>
<td className="px-4 py-3 border-r border-gray-100/50 align-top whitespace-nowrap">
<div className="font-bold flex items-center gap-1.5 text-gray-800">
<User size={12} className="opacity-50" />
{a.user?.name || 'Unassigned'}
</div>
<button onClick={() => { setActiveTab('edit'); setSelectedActivity(a); }} className="text-[9px] font-bold uppercase underline opacity-60 hover:opacity-100 text-gray-800 mt-2 block">
Edit / Reassign
</button>
</td>
<td className="px-4 py-3 align-top min-w-[280px]">
<div className="flex flex-col gap-2 w-full">
<input
type="text"
placeholder={needsFeedback(a.type) ? "Must complete mandatory feedback modal ➔" : "Type remarks & press Mark Done..."}
value={inlineRemarks[a.id] || ''}
onChange={e => setInlineRemarks(prev => ({ ...prev, [a.id]: e.target.value }))}
disabled={needsFeedback(a.type)}
className="w-full px-3 py-1.5 text-xs rounded border border-gray-200 outline-none focus:border-odoo-primary focus:ring-1 focus:ring-odoo-primary bg-white/70"
/>
<div className="flex justify-between items-center mt-1">
<button
onClick={() => handleActionClick(a)}
className="text-[10px] font-bold uppercase tracking-wider text-gray-500 hover:text-odoo-primary underline"
>
Advanced Options
</button>
<button
onClick={() => handleInlineMarkDone(a)}
className="text-[10px] font-black uppercase tracking-wider px-3 py-1.5 bg-black/80 hover:bg-black text-white rounded-lg transition-all shadow-sm flex items-center gap-1 shrink-0"
disabled={submitting}
>
<CheckCircle2 size={12} /> {needsFeedback(a.type) ? 'Open Modal' : 'Mark Done'}
</button>
</div>
</div>
</td>
</tr>
);
if (loading) return <div className="p-8 text-center animate-pulse font-bold text-gray-400">Loading Pipeline Engine...</div>;
return (
<div className="h-full flex flex-col space-y-6 animate-fade-in relative pb-12">
{/* Success Toast */}
{successMsg && (
<div className="fixed top-6 right-6 z-[9999] bg-emerald-500 text-white text-sm font-bold px-5 py-3 rounded-2xl shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
{successMsg}
</div>
)}
{/* Header */}
<div className="bg-gradient-to-br from-slate-900 to-odoo-primary p-6 rounded-2xl shadow-xl text-white flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-2xl font-black tracking-tight flex items-center gap-2">
<AlertTriangle className="text-amber-400" />
Pipeline Activity Engine
</h1>
<p className="text-sm text-white/70 font-medium mt-1">Daily execution & opportunity follow-up management</p>
</div>
<div className="flex items-center gap-3 w-full md:w-auto">
<div className="relative flex-1 md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="Search client, notes..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2 bg-white/10 border border-white/20 rounded-xl text-sm text-white placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-white/50 transition-all"
/>
</div>
<button
onClick={() => setShowNewActivityModal(true)}
className="bg-white text-odoo-primary font-black text-xs px-4 py-2 rounded-xl shadow-lg hover:shadow-xl hover:scale-105 transition-all uppercase tracking-wide whitespace-nowrap"
>
+ New Activity
</button>
</div>
</div>
{/* Filters Panel */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 flex flex-col gap-4">
{/* Date Filters */}
<div className="flex flex-wrap gap-3 items-center">
<span className="text-xs font-black text-gray-500 uppercase tracking-wider shrink-0 w-24">Filter Date:</span>
{/* Preset buttons */}
<div className="flex gap-1.5 flex-wrap">
{[
{ key: 'all', label: 'All' },
{ key: 'today', label: 'Today' },
{ key: 'tomorrow', label: 'Tomorrow' },
{ key: 'week', label: 'This Week' },
{ key: 'month', label: 'This Month' },
{ key: 'custom', label: 'Custom Range' },
].map(p => (
<button
key={p.key}
onClick={() => setDateFilter(prev => ({ ...prev, preset: p.key, from: p.key !== 'custom' ? '' : prev.from, to: p.key !== 'custom' ? '' : prev.to }))}
className={`px-3 py-1.5 rounded-lg text-[11px] font-black uppercase tracking-wide transition-all border ${
dateFilter.preset === p.key
? 'bg-odoo-primary text-white border-odoo-primary shadow-sm'
: 'bg-gray-50 text-gray-500 border-gray-200 hover:border-odoo-primary hover:text-odoo-primary'
}`}
>
{p.label}
</button>
))}
</div>
{/* Custom range inputs */}
{dateFilter.preset === 'custom' && (
<div className="flex gap-2 items-center ml-auto flex-wrap">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-gray-400">From</span>
<input
type="date"
value={dateFilter.from}
onChange={e => setDateFilter(p => ({ ...p, from: e.target.value }))}
className="p-1.5 border border-gray-200 rounded-lg text-xs focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all"
/>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-gray-400">To</span>
<input
type="date"
value={dateFilter.to}
onChange={e => setDateFilter(p => ({ ...p, to: e.target.value }))}
className="p-1.5 border border-gray-200 rounded-lg text-xs focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all"
/>
</div>
</div>
)}
{/* Active date filter clear */}
{dateFilter.preset !== 'all' && dateFilter.preset !== 'custom' && (
<button
onClick={() => setDateFilter({ preset: 'all', from: '', to: '' })}
className="ml-auto flex items-center gap-1 text-[11px] font-black text-rose-500 hover:text-rose-700 transition-colors"
>
<X size={12} /> Clear Date
</button>
)}
</div>
{/* Assignee Filters */}
<div className="flex flex-wrap gap-3 items-center border-t border-gray-50 pt-3">
<span className="text-xs font-black text-gray-500 uppercase tracking-wider shrink-0 w-24">Assignee:</span>
<div className="flex gap-1.5 flex-wrap items-center">
<button
onClick={() => setAssigneeFilter('all')}
className={`px-3 py-1.5 rounded-lg text-[11px] font-black uppercase tracking-wide transition-all border ${
assigneeFilter === 'all'
? 'bg-odoo-primary text-white border-odoo-primary shadow-sm'
: 'bg-gray-50 text-gray-500 border-gray-200 hover:border-odoo-primary hover:text-odoo-primary'
}`}
>
All Assignees
</button>
{user?.id && (
<button
onClick={() => setAssigneeFilter(user.id)}
className={`px-3 py-1.5 rounded-lg text-[11px] font-black uppercase tracking-wide transition-all border ${
assigneeFilter === user.id
? 'bg-odoo-primary text-white border-odoo-primary shadow-sm'
: 'bg-gray-50 text-gray-500 border-gray-200 hover:border-odoo-primary hover:text-odoo-primary'
}`}
>
Assigned to Me
</button>
)}
<select
value={assigneeFilter !== 'all' && assigneeFilter !== user?.id ? assigneeFilter : ''}
onChange={e => {
if (e.target.value) {
setAssigneeFilter(e.target.value);
}
}}
className={`px-3 py-1.5 rounded-lg text-[11px] font-black uppercase tracking-wide transition-all border outline-none cursor-pointer ${
assigneeFilter !== 'all' && assigneeFilter !== user?.id
? 'bg-odoo-primary text-white border-odoo-primary shadow-sm'
: 'bg-white text-gray-500 border-gray-200 hover:border-odoo-primary hover:text-odoo-primary'
}`}
>
<option value="" className="text-gray-700 bg-white">Select Team Member...</option>
{users.filter(u => u.id !== user?.id).map(u => (
<option key={u.id} value={u.id} className="text-gray-700 bg-white">{u.name}</option>
))}
</select>
</div>
{assigneeFilter !== 'all' && (
<button
onClick={() => setAssigneeFilter('all')}
className="ml-auto flex items-center gap-1 text-[11px] font-black text-rose-500 hover:text-rose-700 transition-colors"
>
<X size={12} /> Clear Assignee
</button>
)}
</div>
{/* Category Filters */}
<div className="flex flex-wrap gap-3 items-center border-t border-gray-50 pt-3">
<span className="text-xs font-black text-gray-500 uppercase tracking-wider shrink-0 w-24">Category:</span>
<select
value={categoryFilter}
onChange={e => setCategoryFilter(e.target.value)}
className={`px-3 py-1.5 rounded-lg text-[11px] font-black uppercase tracking-wide transition-all border outline-none cursor-pointer bg-odoo-primary text-white border-odoo-primary shadow-sm`}
>
<option value="today" className="text-gray-700 bg-white">Today's Focus</option>
<option value="tomorrow" className="text-gray-700 bg-white">Tomorrow</option>
<option value="thisWeek" className="text-gray-700 bg-white">Later This Week</option>
<option value="upcoming" className="text-gray-700 bg-white">Upcoming Pipeline</option>
<option value="expired" className="text-gray-700 bg-white">Expired Follow-ups</option>
</select>
</div>
</div>
{/* Grouped Table Layout */}
<div className="flex-1 overflow-y-auto custom-scrollbar flex flex-col gap-8 pb-8">
{/* Today Section */}
{(categoryFilter === 'all' || categoryFilter === 'today') && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden ring-1 ring-emerald-500/20">
<div className="bg-emerald-600 text-white text-xs uppercase tracking-wider px-4 py-3 font-black flex items-center justify-between">
<span className="flex items-center gap-2"><CheckCircle2 size={14} /> Today's Focus</span>
<span className="bg-white/20 px-2 py-0.5 rounded">{categorizedActivities.today.length}</span>
</div>
{categorizedActivities.today.length === 0 ? (
<div className="p-6 text-center text-gray-400 font-bold text-xs bg-emerald-50/20">No activities due today.</div>
) : (
<table className="w-full text-left border-collapse">
<tbody className="text-xs">
{categorizedActivities.today.map(a => <ActivityTableRow key={a.id} a={a} colorClass="bg-orange-50/70 hover:bg-orange-100/50" />)}
</tbody>
</table>
)}
</div>
)}
{/* Tomorrow Section */}
{(categoryFilter === 'all' || categoryFilter === 'tomorrow') && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="bg-amber-500 text-white text-xs uppercase tracking-wider px-4 py-3 font-black flex items-center justify-between">
<span className="flex items-center gap-2"><Calendar size={14} /> Tomorrow</span>
<span className="bg-white/20 px-2 py-0.5 rounded">{categorizedActivities.tomorrow.length}</span>
</div>
{categorizedActivities.tomorrow.length === 0 ? (
<div className="p-6 text-center text-gray-400 font-bold text-xs bg-amber-50/10">No activities planned for tomorrow.</div>
) : (
<table className="w-full text-left border-collapse">
<tbody className="text-xs">
{categorizedActivities.tomorrow.map(a => <ActivityTableRow key={a.id} a={a} colorClass="bg-amber-50/40 hover:bg-amber-50" />)}
</tbody>
</table>
)}
</div>
)}
{/* This Week Section */}
{(categoryFilter === 'all' || categoryFilter === 'thisWeek') && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="bg-blue-500 text-white text-xs uppercase tracking-wider px-4 py-3 font-black flex items-center justify-between">
<span className="flex items-center gap-2"><Calendar size={14} /> Later This Week</span>
<span className="bg-white/20 px-2 py-0.5 rounded">{categorizedActivities.thisWeek.length}</span>
</div>
{categorizedActivities.thisWeek.length === 0 ? (
<div className="p-6 text-center text-gray-400 font-bold text-xs bg-blue-50/10">No other activities planned for this week.</div>
) : (
<table className="w-full text-left border-collapse">
<tbody className="text-xs">
{categorizedActivities.thisWeek.map(a => <ActivityTableRow key={a.id} a={a} colorClass="bg-white hover:bg-gray-50" />)}
</tbody>
</table>
)}
</div>
)}
{/* Upcoming Section */}
{(categoryFilter === 'all' || categoryFilter === 'upcoming') && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="bg-slate-500 text-white text-xs uppercase tracking-wider px-4 py-3 font-black flex items-center justify-between">
<span className="flex items-center gap-2"><Calendar size={14} /> Upcoming (Next Week & Beyond)</span>
<span className="bg-white/20 px-2 py-0.5 rounded">{categorizedActivities.upcoming.length}</span>
</div>
{categorizedActivities.upcoming.length === 0 ? (
<div className="p-6 text-center text-gray-400 font-bold text-xs bg-slate-50/30">No future activities planned.</div>
) : (
<table className="w-full text-left border-collapse">
<tbody className="text-xs">
{categorizedActivities.upcoming.map(a => <ActivityTableRow key={a.id} a={a} colorClass="bg-white hover:bg-gray-50" />)}
</tbody>
</table>
)}
</div>
)}
{/* Expired Section (Moved to Bottom) */}
{(categoryFilter === 'all' || categoryFilter === 'expired') && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden ring-1 ring-red-500/20">
<div className="bg-red-600 text-white text-xs uppercase tracking-wider px-4 py-3 font-black flex items-center justify-between">
<span className="flex items-center gap-2"><AlertTriangle size={14} /> Expired Follow-ups</span>
<span className="bg-white/20 px-2 py-0.5 rounded">{categorizedActivities.expired.length}</span>
</div>
{categorizedActivities.expired.length === 0 ? (
<div className="p-6 text-center text-gray-400 font-bold text-xs bg-red-50/20">No expired follow-ups. Great job!</div>
) : (
<table className="w-full text-left border-collapse">
<tbody className="text-xs">
{categorizedActivities.expired.map(a => <ActivityTableRow key={a.id} a={a} colorClass="bg-red-50/70 hover:bg-red-100/50" />)}
</tbody>
</table>
)}
</div>
)}
</div>
{/* ── FOCUSED SINGLE-ACTIVITY ACTION MODAL ── */}
{selectedActivity !== null && (
<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) setSelectedActivity(null); }}
>
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col animate-in fade-in zoom-in duration-300 max-h-[90vh]">
{/* Modal Header */}
<div className="bg-gradient-to-r from-slate-800 to-slate-700 px-6 py-4 flex justify-between items-center shrink-0">
<div className="flex items-center gap-3">
<div className="bg-white/10 p-2 rounded-xl">
{getTypeIcon(selectedActivity.type)}
</div>
<div>
<p className="text-white font-black text-base leading-tight">
{selectedActivity.client?.companyName || 'Unknown Client'}
</p>
<p className="text-white/60 text-[11px] font-bold uppercase tracking-wider">
{formatTypeLabel(selectedActivity.type)}
</p>
</div>
</div>
<button
onClick={() => setSelectedActivity(null)}
className="text-white/60 hover:text-white hover:bg-white/10 p-2 rounded-xl transition-all"
>
<X size={18} />
</button>
</div>
{/* Activity Details */}
<div className="px-6 py-4 bg-slate-50 border-b border-slate-100 shrink-0">
<div className="flex flex-wrap gap-4 text-sm">
<div className="flex items-center gap-1.5 text-gray-600 font-semibold">
<Clock size={14} className="text-amber-500" />
{format(new Date(selectedActivity.date), 'MMM d, yyyy — h:mm a')}
</div>
{selectedActivity.user && (
<div className="flex items-center gap-1.5 text-gray-600 font-semibold">
<User size={14} className="text-indigo-500" />
{selectedActivity.user.name}
</div>
)}
</div>
{selectedActivity.notes && (
<p className="mt-2 text-sm text-gray-700 bg-white p-3 rounded-xl border border-gray-100">
{selectedActivity.notes}
</p>
)}
</div>
{/* Tab Switcher */}
<div className="flex border-b border-gray-100 shrink-0">
<button
onClick={() => setActiveTab('done')}
className={`flex-1 py-3 text-xs font-black uppercase tracking-wider flex items-center justify-center gap-1.5 transition-all border-b-2 ${
activeTab === 'done'
? 'border-emerald-500 text-emerald-600 bg-emerald-50'
: 'border-transparent text-gray-400 hover:text-gray-600 hover:bg-gray-50'
}`}
>
<CheckCircle2 size={13} /> Mark as Done
</button>
<button
onClick={() => setActiveTab('reschedule')}
className={`flex-1 py-3 text-xs font-black uppercase tracking-wider flex items-center justify-center gap-1.5 transition-all border-b-2 ${
activeTab === 'reschedule'
? 'border-blue-500 text-blue-600 bg-blue-50'
: 'border-transparent text-gray-400 hover:text-gray-600 hover:bg-gray-50'
}`}
>
<CalendarClock size={13} /> Reschedule
</button>
<button
onClick={() => setActiveTab('edit')}
className={`flex-1 py-3 text-xs font-black uppercase tracking-wider flex items-center justify-center gap-1.5 transition-all border-b-2 ${
activeTab === 'edit'
? 'border-violet-500 text-violet-600 bg-violet-50'
: 'border-transparent text-gray-400 hover:text-gray-600 hover:bg-gray-50'
}`}
>
<Edit3 size={13} /> Edit Details
</button>
</div>
{/* Tab Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar px-6 py-5 space-y-4">
{activeTab === 'done' && (
<>
{/* Remarks */}
<div>
<label className="block text-xs font-black text-gray-600 uppercase tracking-wider mb-1.5">
Remarks / Completion Notes
</label>
<textarea
rows={2}
value={feedback.remarks}
onChange={e => setFeedback(p => ({ ...p, remarks: e.target.value }))}
placeholder="Add completion remarks..."
className="w-full p-3 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none resize-none transition-all"
/>
</div>
{/* Mandatory Feedback for DEMO/VISIT */}
{needsFeedback(selectedActivity.type) && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs font-black text-rose-600 uppercase tracking-wider border-t border-dashed border-rose-200 pt-4">
<ClipboardCheck size={14} />
Mandatory Feedback Required to close
</div>
{[
{ key: 'demoPersonName', label: 'Person Met', required: false },
{ key: 'demoContactDetails', label: 'Contact Details', required: false },
{ key: 'customerFeedback', label: 'Customer Feedback', required: true },
{ key: 'requirementDetails', label: 'Requirement Details', required: true },
{ key: 'suggestions', label: 'Suggestions', required: true },
{ key: 'budget', label: 'Budget', required: true },
{ key: 'expectedClosingTimeline', label: 'Expected Closing Timeline', required: true },
{ key: 'competitorInfo', label: 'Competitor Information', required: true },
{ key: 'staffRemarks', label: 'Staff Remarks', required: true },
{ key: 'customerCommitments', label: 'Customer Commitments', required: true },
{ key: 'caCsDetails', label: 'CA / CS Reference', required: true },
].map(({ key, label, required }) => (
<div key={key}>
<label className="block text-xs font-bold text-gray-600 mb-1">
{label}{required && <span className="text-rose-500 ml-1">*</span>}
</label>
<textarea
rows={2}
value={feedback[key as keyof typeof feedback]}
onChange={e => setFeedback(p => ({ ...p, [key]: e.target.value }))}
placeholder={`Enter ${label.toLowerCase()}...`}
className="w-full p-2.5 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none resize-none transition-all"
/>
</div>
))}
</div>
)}
</>
)}
{activeTab === 'reschedule' && (
<div className="space-y-4">
<div className="flex items-center gap-2 text-xs font-black text-blue-600 uppercase tracking-wider">
<CalendarClock size={14} />
Set a new date &amp; time
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">
New Date <span className="text-rose-500">*</span>
</label>
<input
type="date"
value={reschedule.date}
min={new Date().toISOString().split('T')[0]}
onChange={e => setReschedule(p => ({ ...p, date: e.target.value }))}
className="w-full p-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
/>
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">
New Time
</label>
<input
type="time"
value={reschedule.time}
onChange={e => setReschedule(p => ({ ...p, time: e.target.value }))}
className="w-full p-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">
Reason for Rescheduling
</label>
<textarea
rows={3}
value={reschedule.reason}
onChange={e => setReschedule(p => ({ ...p, reason: e.target.value }))}
placeholder="Why is this being rescheduled? (optional)"
className="w-full p-3 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none resize-none transition-all"
/>
</div>
<div className="bg-blue-50 border border-blue-100 rounded-xl p-3 text-xs text-blue-700 font-semibold flex gap-2">
<RefreshCw size={14} className="shrink-0 mt-0.5" />
The activity will be rescheduled to the new date and its status reset to Pending.
</div>
</div>
)}
{activeTab === 'edit' && (
<div className="space-y-4">
<div className="flex items-center gap-2 text-xs font-black text-violet-600 uppercase tracking-wider">
<Edit3 size={14} />
Edit activity details
</div>
{/* Assignee */}
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">
<Users size={12} className="inline mr-1" />
Assigned To
</label>
<select
value={editDetails.userId}
onChange={e => setEditDetails(p => ({ ...p, userId: e.target.value }))}
className="w-full p-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none transition-all bg-white"
>
<option value=""> Select Assignee </option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
{selectedActivity.user && (
<p className="text-[11px] text-gray-400 mt-1">
Currently assigned to: <span className="font-bold text-gray-600">{selectedActivity.user.name}</span>
</p>
)}
</div>
{/* Remarks / Notes */}
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">
Remarks / Notes
</label>
<textarea
rows={4}
value={editDetails.notes}
onChange={e => setEditDetails(p => ({ ...p, notes: e.target.value }))}
placeholder="Edit remarks or notes for this activity..."
className="w-full p-3 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-violet-500 focus:border-violet-500 outline-none resize-none transition-all"
/>
</div>
</div>
)}
</div>
{/* Footer Actions */}
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50 flex gap-3 shrink-0">
<button
onClick={() => setSelectedActivity(null)}
className="flex-1 py-2.5 border border-gray-200 text-gray-600 rounded-xl text-sm font-bold hover:bg-gray-100 transition-all"
>
Cancel
</button>
{activeTab === 'done' ? (
<button
onClick={handleMarkDone}
disabled={submitting}
className="flex-1 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl text-sm font-black transition-all flex items-center justify-center gap-2 shadow-lg shadow-emerald-500/30 disabled:opacity-60"
>
<CheckCircle2 size={16} />
{submitting ? 'Saving...' : 'Mark as Done'}
</button>
) : activeTab === 'reschedule' ? (
<button
onClick={handleReschedule}
disabled={submitting || !reschedule.date}
className="flex-1 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-xl text-sm font-black transition-all flex items-center justify-center gap-2 shadow-lg shadow-blue-500/30 disabled:opacity-60"
>
<CalendarClock size={16} />
{submitting ? 'Saving...' : 'Confirm Reschedule'}
</button>
) : (
<button
onClick={handleEditDetails}
disabled={submitting}
className="flex-1 py-2.5 bg-violet-500 hover:bg-violet-600 text-white rounded-xl text-sm font-black transition-all flex items-center justify-center gap-2 shadow-lg shadow-violet-500/30 disabled:opacity-60"
>
<Edit3 size={16} />
{submitting ? 'Saving...' : 'Save Changes'}
</button>
)}
</div>
</div>
</div>
)}
{/* ── NEW ACTIVITY MODAL (Full ActivitiesManager) ── */}
{showNewActivityModal && (
<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) { setShowNewActivityModal(false); fetchActivities(); } }}
>
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-6xl h-[90vh] overflow-hidden flex flex-col animate-in fade-in zoom-in duration-300">
<div className="bg-gray-50 px-6 py-4 flex justify-between items-center border-b border-gray-200 shrink-0">
<h2 className="font-black text-gray-800 text-lg uppercase tracking-wider">Activity Command Center</h2>
<button
onClick={() => { setShowNewActivityModal(false); fetchActivities(); }}
className="text-gray-500 hover:bg-gray-200 p-2 rounded-xl transition-colors font-bold text-sm flex items-center gap-1"
>
<X size={16} /> Close
</button>
</div>
<div className="flex-1 overflow-hidden p-6 bg-gray-100">
<ActivitiesManager />
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,14 +1,51 @@
'use client';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import api from '../lib/axios';
import { Plus, Trash2, Layers, IndianRupee, FileText, CheckCircle2, ChevronDown, ChevronUp, Sparkles, Pencil, X, ShieldCheck, Users } from 'lucide-react';
interface AppUser {
id: string;
name: string;
email: string;
role: string;
}
interface UserProductDiscount {
id: string;
userId: string;
productId: string;
schemeId: string | null;
maxDiscountPercentage: number;
user: AppUser;
scheme: { id: string; name: string; price: number } | null;
}
interface Scheme {
id?: string;
name: string;
price: number;
features: string;
brochureUrl?: string;
maxDiscountPercentage?: number;
}
interface Addon {
id?: string;
name: string;
price: number;
description?: string;
}
interface Product {
id: string;
name: string;
description: string;
price: number;
// imageUrl removed
brochureUrl?: string;
maxDiscountPercentage?: number;
schemes?: Scheme[];
addons?: Addon[];
}
export default function ProductManager() {
@ -17,49 +54,221 @@ export default function ProductManager() {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [price, setPrice] = useState('');
// const [imageUrl, setImageUrl] = useState(''); // Removed
const [brochureUrl, setBrochureUrl] = useState('');
const [maxDiscountPercentage, setMaxDiscountPercentage] = useState('');
const [uploadingProductBrochure, setUploadingProductBrochure] = useState(false);
const [creating, setCreating] = useState(false);
const [editingProductId, setEditingProductId] = useState<string | null>(null);
// Scheme State
const [schemes, setSchemes] = useState<Scheme[]>([]);
const [schemeName, setSchemeName] = useState('');
const [schemePrice, setSchemePrice] = useState('');
const [schemeFeatures, setSchemeFeatures] = useState('');
const [schemeBrochureUrl, setSchemeBrochureUrl] = useState('');
const [schemeMaxDiscount, setSchemeMaxDiscount] = useState('');
const [uploadingSchemeBrochure, setUploadingSchemeBrochure] = useState(false);
// Addon State
const [addons, setAddons] = useState<Addon[]>([]);
const [addonName, setAddonName] = useState('');
const [addonPrice, setAddonPrice] = useState('');
const [addonDescription, setAddonDescription] = useState('');
const [expandedProductId, setExpandedProductId] = useState<string | null>(null);
// User discount caps state
const [allUsers, setAllUsers] = useState<AppUser[]>([]);
const [userDiscounts, setUserDiscounts] = useState<Record<string, UserProductDiscount[]>>({}); // keyed by productId
const [discountFormUserId, setDiscountFormUserId] = useState('');
const [discountFormSchemeId, setDiscountFormSchemeId] = useState<string>(''); // empty = product-level
const [discountFormValue, setDiscountFormValue] = useState('');
const [savingDiscount, setSavingDiscount] = useState(false);
useEffect(() => {
fetchProducts();
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
const res = await api.get('/users');
setAllUsers((res.data || []).filter((u: AppUser) => u.role !== 'ADMIN'));
} catch { /* silent */ }
};
const fetchUserDiscounts = useCallback(async (productId: string) => {
try {
const res = await api.get(`/products/${productId}/user-discounts`);
setUserDiscounts(prev => ({ ...prev, [productId]: res.data }));
} catch { /* silent */ }
}, []);
const fetchProducts = async () => {
try {
setLoading(true);
const response = await api.get('/products');
setProducts(response.data);
setLoading(false);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const handleCreate = async (e: React.FormEvent) => {
const getFileUrl = (url: string) => {
if (!url) 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 handleBrochureUpload = async (e: React.ChangeEvent<HTMLInputElement>, isScheme: boolean) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
const uploadFormData = new FormData();
uploadFormData.append('file', file);
if (isScheme) setUploadingSchemeBrochure(true);
else setUploadingProductBrochure(true);
try {
const res = await api.post('/upload', uploadFormData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (isScheme) {
setSchemeBrochureUrl(res.data.url);
} else {
setBrochureUrl(res.data.url);
}
} catch (err) {
console.error('Upload failed', err);
alert(`Failed to upload ${file.name}`);
} finally {
if (isScheme) setUploadingSchemeBrochure(false);
else setUploadingProductBrochure(false);
}
}
};
const handleAddSchemeToDraft = () => {
if (!schemeName || !schemePrice) {
alert('Please enter both a Scheme Name and Price');
return;
}
const newScheme: Scheme = {
name: schemeName,
price: parseFloat(schemePrice),
features: schemeFeatures,
brochureUrl: schemeBrochureUrl || undefined,
maxDiscountPercentage: schemeMaxDiscount !== '' ? parseFloat(schemeMaxDiscount) : undefined,
};
setSchemes([...schemes, newScheme]);
setSchemeName('');
setSchemePrice('');
setSchemeFeatures('');
setSchemeBrochureUrl('');
setSchemeMaxDiscount('');
};
const handleRemoveSchemeFromDraft = (index: number) => {
setSchemes(schemes.filter((_, idx) => idx !== index));
};
const handleAddAddonToDraft = () => {
if (!addonName || !addonPrice) {
alert('Please enter both an Addon Name and Price');
return;
}
const newAddon: Addon = {
name: addonName,
price: parseFloat(addonPrice),
description: addonDescription,
};
setAddons([...addons, newAddon]);
setAddonName('');
setAddonPrice('');
setAddonDescription('');
};
const handleRemoveAddonFromDraft = (index: number) => {
setAddons(addons.filter((_, idx) => idx !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setCreating(true);
try {
await api.post('/products', {
// Strip autogenerated fields like id, productId, createdAt, and updatedAt from schemes/addons to let NestJS/Prisma re-create them cleanly
const processedSchemes = schemes.map(({ id, productId, createdAt, updatedAt, ...rest }: any) => rest);
const processedAddons = addons.map(({ id, productId, createdAt, updatedAt, ...rest }: any) => rest);
const payload = {
name,
description,
price: parseFloat(price),
// imageUrl
});
brochureUrl: brochureUrl || undefined,
maxDiscountPercentage: maxDiscountPercentage !== '' ? parseFloat(maxDiscountPercentage) : undefined,
schemes: processedSchemes,
addons: processedAddons,
};
if (editingProductId) {
await api.patch(`/products/${editingProductId}`, payload);
setEditingProductId(null);
} else {
await api.post('/products', payload);
}
setName('');
setDescription('');
setPrice('');
// setImageUrl('');
setBrochureUrl('');
setMaxDiscountPercentage('');
setSchemes([]);
setAddons([]);
fetchProducts();
} catch (error) {
console.error(error);
alert('Failed to create product');
alert('Failed to save product');
} finally {
setCreating(false);
}
};
const handleEdit = (product: Product) => {
setEditingProductId(product.id);
setName(product.name);
setDescription(product.description || '');
setPrice(String(product.price));
setBrochureUrl(product.brochureUrl || '');
setMaxDiscountPercentage(product.maxDiscountPercentage != null ? String(product.maxDiscountPercentage) : '');
setSchemes(product.schemes || []);
setAddons(product.addons || []);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleCancelEdit = () => {
setEditingProductId(null);
setName('');
setDescription('');
setPrice('');
setBrochureUrl('');
setMaxDiscountPercentage('');
setSchemes([]);
setAddons([]);
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure?')) return;
if (!confirm('Are you sure you want to delete this product? All of its pricing schemes and addons will be deleted too.')) return;
try {
await api.delete(`/products/${id}`);
fetchProducts();
@ -69,57 +278,599 @@ export default function ProductManager() {
}
};
return (
<div className="odoo-card p-6 mb-8">
<h3 className="text-xl font-bold mb-4 text-gray-800">Product Management</h3>
const toggleExpandProduct = (id: string) => {
const next = expandedProductId === id ? null : id;
setExpandedProductId(next);
if (next) {
fetchUserDiscounts(next);
// reset discount form
setDiscountFormUserId('');
setDiscountFormSchemeId('');
setDiscountFormValue('');
}
};
<form onSubmit={handleCreate} className="mb-8 grid grid-cols-1 md:grid-cols-2 gap-6 bg-gray-50 p-6 rounded-xl border border-gray-100">
const handleSaveUserDiscount = async (productId: string) => {
if (!discountFormUserId || discountFormValue === '') {
alert('Please select a user and enter a max discount %');
return;
}
setSavingDiscount(true);
try {
await api.post(`/products/${productId}/user-discounts`, {
userId: discountFormUserId,
schemeId: discountFormSchemeId || null,
maxDiscountPercentage: parseFloat(discountFormValue),
});
setDiscountFormUserId('');
setDiscountFormSchemeId('');
setDiscountFormValue('');
fetchUserDiscounts(productId);
} catch (e) {
alert('Failed to save discount cap');
} finally {
setSavingDiscount(false);
}
};
const handleRemoveUserDiscount = async (productId: string, discountId: string) => {
try {
await api.delete(`/products/${productId}/user-discounts/${discountId}`);
fetchUserDiscounts(productId);
} catch { alert('Failed to remove discount cap'); }
};
return (
<div className="flex flex-col space-y-8 animate-fade-in pb-12">
{/* Header section with Stats */}
<div className="bg-gradient-to-br from-slate-900 to-odoo-primary p-6 rounded-2xl shadow-xl text-white flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Name</label>
<input type="text" value={name} onChange={e => setName(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" required />
<h1 className="text-2xl font-black tracking-tight flex items-center gap-2">
<Layers className="text-amber-400" />
Product Catalog &amp; Schemes
</h1>
<p className="text-sm text-white/70 font-medium mt-1">Manage core products, pricing schemes, and feature specs</p>
</div>
<div className="bg-white/10 px-4 py-2 rounded-xl border border-white/15 shadow-sm text-center shrink-0">
<span className="text-[10px] font-black uppercase text-white/60 block">Catalog Volume</span>
<span className="text-lg font-black">{products.length} Products</span>
</div>
</div>
<div className="flex flex-col gap-6">
{/* Product & Schemes Addition Form */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<div className="flex justify-between items-start">
<div>
<h3 className="text-base font-black text-gray-800 uppercase tracking-wider flex items-center gap-2">
<Sparkles size={16} className="text-odoo-primary" />
{editingProductId ? 'Edit Product' : 'Add New Product'}
</h3>
<p className="text-[11px] text-gray-400 font-bold uppercase mt-0.5">
{editingProductId ? 'Update pricing and scheme specifications' : 'Setup product with custom tiers'}
</p>
</div>
{editingProductId && (
<button
onClick={handleCancelEdit}
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
title="Cancel Edit"
>
<X size={16} />
</button>
)}
</div>
<form onSubmit={handleSubmit} className="mt-5 space-y-5">
{/* Row 1: Name + Price + Max Discount */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-black text-gray-500 uppercase tracking-wider mb-1">Product Name *</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full p-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all"
placeholder="e.g. IgCRM Enterprise"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Price ()</label>
<input type="number" value={price} onChange={e => setPrice(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" required />
<label className="block text-xs font-black text-gray-500 uppercase tracking-wider mb-1">Base Price () *</label>
<div className="relative">
<IndianRupee size={14} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="number"
onWheel={(e) => e.currentTarget.blur()}
value={price}
onChange={e => setPrice(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none transition-all"
placeholder="e.g. 15000"
required
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">Description</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
</div>
{/* Image URL field removed */}
<div className="md:col-span-2 pt-2">
<button type="submit" disabled={creating} className="bg-odoo-primary text-white px-6 py-2.5 rounded-lg hover:bg-odoo-primary/90 font-bold shadow-sm transition-all transform hover:-translate-y-0.5">
{creating ? 'Saving...' : 'Add Product'}
<div>
<label className="block text-xs font-black text-gray-500 uppercase tracking-wider mb-1">Max Discount % (Product Level)</label>
<div className="relative">
<span className="absolute right-3.5 top-1/2 -translate-y-1/2 text-gray-400 text-xs font-bold">%</span>
<input
type="number"
onWheel={(e) => e.currentTarget.blur()}
min="0"
max="100"
step="0.01"
value={maxDiscountPercentage}
onChange={e => setMaxDiscountPercentage(e.target.value)}
className="w-full pr-9 pl-3 py-2.5 border border-amber-200 bg-amber-50/40 rounded-xl text-sm focus:ring-2 focus:ring-amber-400 focus:border-amber-400 outline-none transition-all font-bold text-gray-700"
placeholder="e.g. 15 (leave blank = no cap)"
/>
</div>
<p className="text-[10px] text-amber-600 font-bold mt-1"> Max % discount a sales user can offer for this product</p>
</div>
</div>
{/* Row 2: Description + Brochure */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black text-gray-500 uppercase tracking-wider mb-1">Product Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
className="w-full p-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none resize-none transition-all"
rows={2}
placeholder="General product description or comments..."
/>
</div>
<div>
<label className="block text-xs font-black text-gray-500 uppercase tracking-wider mb-1">Product Brochure (PDF/Image)</label>
{brochureUrl ? (
<div className="flex items-center justify-between p-2.5 bg-indigo-50 border border-indigo-100 rounded-xl text-xs h-[74px]">
<div className="flex items-center gap-2 overflow-hidden">
<FileText size={16} className="text-indigo-600 shrink-0" />
<a href={getFileUrl(brochureUrl)} target="_blank" rel="noopener noreferrer" className="font-extrabold text-indigo-700 truncate hover:underline">
View Brochure
</a>
</div>
<button type="button" onClick={() => setBrochureUrl('')} className="text-rose-500 hover:text-rose-700 font-bold">Remove</button>
</div>
) : (
<label className="flex items-center justify-center gap-2 border border-dashed border-gray-200 hover:border-indigo-500 rounded-xl p-4 cursor-pointer bg-slate-50 hover:bg-indigo-50/10 transition-all h-[74px]">
<FileText size={18} className="text-gray-400" />
<span className="text-xs font-bold text-gray-500">{uploadingProductBrochure ? 'Uploading...' : 'Upload Base Product Brochure'}</span>
<input type="file" className="hidden" accept=".pdf,image/*" onChange={e => handleBrochureUpload(e, false)} disabled={uploadingProductBrochure} />
</label>
)}
</div>
</div>
{/* Row 3: Schemes + Addons side by side */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 border-t border-dashed border-gray-200 pt-5">
{/* Schemes Sub-form */}
<div>
<div className="flex justify-between items-center mb-3">
<div>
<span className="block text-xs font-black text-gray-600 uppercase tracking-wider">Product Schemes / Tiers</span>
<span className="text-[10px] text-gray-400 font-bold uppercase block">Add custom price variants and features</span>
</div>
<span className="bg-indigo-50 border border-indigo-100 text-indigo-700 text-[10px] font-black uppercase px-2 py-0.5 rounded-full">{schemes.length} Added</span>
</div>
{schemes.length > 0 && (
<div className="space-y-2 mb-3 max-h-36 overflow-y-auto custom-scrollbar p-1">
{schemes.map((sch, sIdx) => (
<div key={sIdx} className="flex justify-between items-center bg-indigo-50/40 border border-indigo-100 p-2.5 rounded-xl text-xs">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-extrabold text-gray-800">{sch.name}</span>
<span className="font-black text-indigo-600 bg-white px-1.5 py-0.5 rounded border border-indigo-100 text-[10px]">{sch.price}</span>
{sch.maxDiscountPercentage != null && (
<span className="font-black text-amber-700 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded text-[10px]">Max {sch.maxDiscountPercentage}% off</span>
)}
</div>
{sch.features && <p className="text-[10px] text-gray-400 mt-0.5 truncate">{sch.features}</p>}
{sch.brochureUrl && (
<span className="inline-flex items-center gap-1 text-[9px] text-indigo-600 bg-indigo-100/50 border border-indigo-100 px-1.5 py-0.5 rounded font-extrabold mt-1">
<FileText size={10} /> Brochure Attached
</span>
)}
</div>
<button type="button" onClick={() => handleRemoveSchemeFromDraft(sIdx)} className="p-1 text-rose-500 hover:text-rose-700 hover:bg-rose-50 rounded-lg transition-colors ml-2"><Trash2 size={14} /></button>
</div>
))}
</div>
)}
<div className="bg-slate-50/80 p-3.5 border border-gray-100 rounded-2xl space-y-2.5">
<input type="text" value={schemeName} onChange={e => setSchemeName(e.target.value)} placeholder="Scheme Name (e.g. Standard 5 Users)" className="w-full p-2 border border-gray-200 rounded-lg text-xs bg-white focus:ring-2 focus:ring-indigo-500 outline-none transition-all font-bold text-gray-700" />
<div className="relative">
<IndianRupee size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input type="number" onWheel={(e) => e.currentTarget.blur()} value={schemePrice} onChange={e => setSchemePrice(e.target.value)} placeholder="Scheme Price (₹)" className="w-full pl-7 pr-2 p-2 border border-gray-200 rounded-lg text-xs bg-white focus:ring-2 focus:ring-indigo-500 outline-none transition-all font-bold text-gray-700" />
</div>
<textarea value={schemeFeatures} onChange={e => setSchemeFeatures(e.target.value)} placeholder="Specs / Features (e.g. Unlimited calls, 10GB cloud)" className="w-full p-2 border border-gray-200 rounded-lg text-xs bg-white focus:ring-2 focus:ring-indigo-500 outline-none resize-none transition-all text-gray-600" rows={2} />
<div className="relative">
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-amber-500 text-[10px] font-black">%</span>
<input type="number" onWheel={(e) => e.currentTarget.blur()} min="0" max="100" step="0.01" value={schemeMaxDiscount} onChange={e => setSchemeMaxDiscount(e.target.value)} placeholder="Max Discount % for this scheme (optional)" className="w-full pr-8 pl-2 p-2 border border-amber-200 bg-amber-50/30 rounded-lg text-xs focus:ring-2 focus:ring-amber-400 outline-none transition-all font-bold text-gray-700" />
</div>
{schemeBrochureUrl ? (
<div className="flex items-center justify-between p-2 bg-indigo-50/50 border border-indigo-100 rounded-lg text-[11px]">
<span className="font-extrabold text-indigo-700 truncate">Scheme Brochure Attached</span>
<button type="button" onClick={() => setSchemeBrochureUrl('')} className="text-rose-500 hover:text-rose-700 text-[10px] font-black uppercase">Remove</button>
</div>
) : (
<label className="flex items-center justify-center border border-dashed border-gray-300 hover:border-indigo-400 rounded-lg p-2.5 cursor-pointer bg-white hover:bg-indigo-50/5 transition-all text-center">
<FileText size={14} className="text-gray-400 mr-1.5" />
<span className="text-[11px] font-bold text-gray-500">{uploadingSchemeBrochure ? 'Uploading...' : 'Upload Scheme Brochure'}</span>
<input type="file" className="hidden" accept=".pdf,image/*" onChange={e => handleBrochureUpload(e, true)} disabled={uploadingSchemeBrochure} />
</label>
)}
<button type="button" onClick={handleAddSchemeToDraft} className="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-black text-[11px] uppercase tracking-wider rounded-xl transition-all shadow-sm flex items-center justify-center gap-1">
<Plus size={12} /> Add Scheme to Product
</button>
</div>
</form>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Price ()</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
{/* Addons Sub-form */}
<div>
<div className="flex justify-between items-center mb-3">
<div>
<span className="block text-xs font-black text-gray-600 uppercase tracking-wider">Product Addons</span>
<span className="text-[10px] text-gray-400 font-bold uppercase block">Add optional features with separate pricing</span>
</div>
<span className="bg-emerald-50 border border-emerald-100 text-emerald-700 text-[10px] font-black uppercase px-2 py-0.5 rounded-full">{addons.length} Added</span>
</div>
{addons.length > 0 && (
<div className="space-y-2 mb-3 max-h-36 overflow-y-auto custom-scrollbar p-1">
{addons.map((addon, aIdx) => (
<div key={aIdx} className="flex justify-between items-center bg-emerald-50/40 border border-emerald-100 p-2.5 rounded-xl text-xs">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-extrabold text-gray-800">{addon.name}</span>
<span className="font-black text-emerald-600 bg-white px-1.5 py-0.5 rounded border border-emerald-100 text-[10px]">{addon.price}</span>
</div>
{addon.description && <p className="text-[10px] text-gray-400 mt-0.5 truncate">{addon.description}</p>}
</div>
<button type="button" onClick={() => handleRemoveAddonFromDraft(aIdx)} className="p-1 text-rose-500 hover:text-rose-700 hover:bg-rose-50 rounded-lg transition-colors ml-2"><Trash2 size={14} /></button>
</div>
))}
</div>
)}
<div className="bg-slate-50/80 p-3.5 border border-gray-100 rounded-2xl space-y-2.5">
<input type="text" value={addonName} onChange={e => setAddonName(e.target.value)} placeholder="Addon Name (e.g. WhatsApp Integration)" className="w-full p-2 border border-gray-200 rounded-lg text-xs bg-white focus:ring-2 focus:ring-emerald-500 outline-none transition-all font-bold text-gray-700" />
<div className="relative">
<IndianRupee size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input type="number" onWheel={(e) => e.currentTarget.blur()} value={addonPrice} onChange={e => setAddonPrice(e.target.value)} placeholder="Addon Price (₹)" className="w-full pl-7 pr-2 p-2 border border-gray-200 rounded-lg text-xs bg-white focus:ring-2 focus:ring-emerald-500 outline-none transition-all font-bold text-gray-700" />
</div>
<textarea value={addonDescription} onChange={e => setAddonDescription(e.target.value)} placeholder="Addon Description (e.g. Enables automated notifications)" className="w-full p-2 border border-gray-200 rounded-lg text-xs bg-white focus:ring-2 focus:ring-emerald-500 outline-none resize-none transition-all text-gray-600" rows={3} />
<button type="button" onClick={handleAddAddonToDraft} className="w-full py-2 bg-emerald-600 hover:bg-emerald-700 text-white font-black text-[11px] uppercase tracking-wider rounded-xl transition-all shadow-sm flex items-center justify-center gap-1">
<Plus size={12} /> Add Addon to Product
</button>
</div>
</div>
</div>
{/* Submit / Cancel */}
<div className="flex items-center gap-3 pt-2 border-t border-gray-100">
<button
type="submit"
disabled={creating}
className="flex-1 bg-odoo-primary hover:bg-odoo-primary/90 text-white font-black py-3 rounded-2xl shadow-lg hover:shadow-xl transition-all uppercase tracking-wider text-xs flex items-center justify-center gap-1.5 disabled:opacity-60"
>
<CheckCircle2 size={14} />
{creating ? 'Saving...' : editingProductId ? 'Update Product Details' : 'Add Product to Catalog'}
</button>
{editingProductId && (
<button
type="button"
onClick={handleCancelEdit}
className="flex-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold py-3 rounded-2xl text-xs transition-all uppercase tracking-wider flex items-center justify-center gap-1"
>
<X size={12} /> Cancel Edit
</button>
)}
</div>
</form>
</div>
{/* Products List & Schemes Details Grid */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 space-y-6">
<div>
<h3 className="text-base font-black text-gray-800 uppercase tracking-wider">Product Catalog</h3>
<p className="text-[11px] text-gray-400 font-bold uppercase mt-0.5">Explore active products and multi-specs pricing models</p>
</div>
{loading ? (
<div className="py-12 text-center text-xs font-bold text-gray-400 animate-pulse uppercase tracking-wider">Loading products catalog...</div>
) : products.length === 0 ? (
<div className="py-12 text-center text-xs font-bold text-gray-400 italic">No products registered in the catalog yet. Use the left form to add your first product!</div>
) : (
<div className="space-y-4">
{products.map((product) => {
const isExpanded = expandedProductId === product.id;
const schemesCount = product.schemes?.length || 0;
const addonsCount = product.addons?.length || 0;
const hasDetails = schemesCount > 0 || addonsCount > 0;
return (
<div key={product.id} className="border border-gray-100 rounded-2xl hover:shadow-sm transition-all overflow-hidden bg-slate-50/50">
<div className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-white border-b border-gray-100">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-extrabold text-gray-800 text-sm">{product.name}</h4>
<span className="text-[10px] font-black bg-emerald-50 border border-emerald-100 text-emerald-700 px-2 py-0.5 rounded-full">
Base Price: {product.price.toLocaleString()}
</span>
{product.maxDiscountPercentage != null && (
<span className="text-[10px] font-black bg-amber-50 border border-amber-200 text-amber-700 px-2 py-0.5 rounded-full">
Max Disc: {product.maxDiscountPercentage}%
</span>
)}
{schemesCount > 0 && (
<span className="text-[10px] font-black bg-indigo-50 border border-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full">
{schemesCount} Schemes
</span>
)}
{addonsCount > 0 && (
<span className="text-[10px] font-black bg-teal-50 border border-teal-100 text-teal-700 px-2 py-0.5 rounded-full">
{addonsCount} Addons
</span>
)}
</div>
{product.description && (
<p className="text-xs text-gray-500 mt-1 line-clamp-1">{product.description}</p>
)}
{product.brochureUrl && (
<div className="mt-2">
<a
href={getFileUrl(product.brochureUrl)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-800 hover:underline bg-indigo-50 border border-indigo-100 px-2.5 py-1 rounded-lg"
>
<FileText size={12} /> Product Brochure
</a>
</div>
)}
</div>
<div className="flex items-center gap-2 self-end sm:self-auto shrink-0">
<button
onClick={() => toggleExpandProduct(product.id)}
className="p-2 border border-gray-200 text-gray-500 hover:text-gray-800 hover:bg-gray-50 rounded-xl transition-all text-xs font-extrabold flex items-center gap-1"
>
{isExpanded ? (
<>Hide Details <ChevronUp size={14} /></>
) : (
<>Show Details <ChevronDown size={14} /></>
)}
</button>
<button
onClick={() => handleEdit(product)}
className={`p-2 border rounded-xl transition-all ${editingProductId === product.id ? 'bg-indigo-50 border-indigo-200 text-indigo-600 font-bold' : 'border-indigo-100 text-indigo-500 hover:text-indigo-700 hover:bg-indigo-50'}`}
title="Edit Product & Schemes"
>
<Pencil size={15} />
</button>
<button
onClick={() => handleDelete(product.id)}
className="p-2 border border-rose-100 text-rose-500 hover:text-rose-700 hover:bg-rose-50 rounded-xl transition-all"
title="Delete Product"
>
<Trash2 size={15} />
</button>
</div>
</div>
{/* Product Schemes, Addons & User Discount Caps */}
{isExpanded && (
<div className="p-4 bg-indigo-50/10 border-t border-indigo-50/50 space-y-4">
{schemesCount > 0 && (
<div>
<div className="flex items-center gap-2 mb-2">
<div className="w-1 h-3.5 rounded bg-indigo-500" />
<span className="text-[10px] font-black text-indigo-600 uppercase tracking-widest">Available Schemes &amp; Specs (Base MRPs)</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{product.schemes?.map((sch) => (
<div key={sch.id} className="bg-white border border-indigo-50/60 p-3 rounded-xl flex flex-col gap-2 shadow-sm">
<div className="flex justify-between items-start gap-2">
<span className="font-extrabold text-xs text-slate-800">{sch.name}</span>
<span className="text-[10px] font-black text-indigo-600 bg-indigo-50 border border-indigo-100 px-2 py-0.5 rounded-md whitespace-nowrap">
{sch.price.toLocaleString()}
</span>
</div>
{sch.maxDiscountPercentage != null && (
<div className="flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 text-[10px] font-black text-amber-700 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-md">
🏷 Max Discount: {sch.maxDiscountPercentage}%
</span>
</div>
)}
{sch.features && (
<p className="text-[11px] text-slate-500 font-medium leading-relaxed bg-slate-50 p-2 rounded-lg border border-slate-100 whitespace-pre-wrap">
{sch.features}
</p>
)}
{sch.brochureUrl && (
<div className="mt-1">
<a
href={getFileUrl(sch.brochureUrl)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] font-bold text-indigo-600 hover:underline"
>
<FileText size={11} /> Scheme Brochure
</a>
</div>
)}
</div>
))}
</div>
</div>
)}
{addonsCount > 0 && (
<div>
<div className="flex items-center gap-2 mb-2">
<div className="w-1 h-3.5 rounded bg-teal-500" />
<span className="text-[10px] font-black text-teal-600 uppercase tracking-widest">Available Optional Addons</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{product.addons?.map((addon) => (
<div key={addon.id} className="bg-white border border-teal-50/60 p-3 rounded-xl flex flex-col gap-2 shadow-sm">
<div className="flex justify-between items-start gap-2">
<span className="font-extrabold text-xs text-slate-800">{addon.name}</span>
<span className="text-[10px] font-black text-teal-600 bg-teal-50 border border-teal-100 px-2 py-0.5 rounded-md whitespace-nowrap">
{addon.price.toLocaleString()}
</span>
</div>
{addon.description && (
<p className="text-[11px] text-slate-500 font-medium leading-relaxed bg-slate-50 p-2 rounded-lg border border-slate-100 whitespace-pre-wrap">
{addon.description}
</p>
)}
</div>
))}
</div>
</div>
)}
{/* ─── Per-User Discount Caps Section ─── */}
<div className="border-t border-amber-100 pt-4 mt-2">
<div className="flex items-center gap-2 mb-3">
<div className="w-1 h-3.5 rounded bg-amber-500" />
<span className="text-[10px] font-black text-amber-700 uppercase tracking-widest">Per-User Discount Caps</span>
<span className="text-[9px] text-amber-500 font-bold">(max % each user can discount this product or its schemes)</span>
</div>
{/* Existing caps table */}
{(userDiscounts[product.id] || []).length > 0 && (
<div className="mb-3 overflow-x-auto">
<table className="w-full text-[11px]">
<thead>
<tr className="bg-amber-50 border border-amber-100 rounded-lg">
<th className="text-left font-black text-amber-700 px-3 py-2 uppercase tracking-wider">User</th>
<th className="text-left font-black text-amber-700 px-3 py-2 uppercase tracking-wider">Level</th>
<th className="text-center font-black text-amber-700 px-3 py-2 uppercase tracking-wider">Max Discount</th>
<th className="px-2 py-2"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{loading ? <tr><td colSpan={4} className="text-center py-4">Loading...</td></tr> :
products.length === 0 ? <tr><td colSpan={4} className="text-center py-4">No products found</td></tr> :
products.map(product => (
<tr key={product.id}>
<td className="px-6 py-4 whitespace-nowrap">{product.name}</td>
<td className="px-6 py-4 whitespace-nowrap">{product.price}</td>
<td className="px-6 py-4">{product.description}</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button onClick={() => handleDelete(product.id)} className="text-rose-600 hover:text-rose-900 font-semibold">Delete</button>
<tbody>
{(userDiscounts[product.id] || []).map((d) => (
<tr key={d.id} className="border-b border-amber-50 hover:bg-amber-50/30 transition-colors">
<td className="px-3 py-2">
<div className="font-extrabold text-gray-800">{d.user.name || d.user.email}</div>
<div className="text-[9px] text-gray-400 font-bold uppercase">{d.user.role.replace(/_/g,' ')}</div>
</td>
<td className="px-3 py-2">
{d.scheme ? (
<span className="inline-flex items-center gap-1 bg-indigo-50 border border-indigo-100 text-indigo-700 font-bold px-2 py-0.5 rounded-full text-[10px]">
{d.scheme.name}
</span>
) : (
<span className="inline-flex items-center gap-1 bg-gray-100 border border-gray-200 text-gray-600 font-bold px-2 py-0.5 rounded-full text-[10px]">
All Schemes
</span>
)}
</td>
<td className="px-3 py-2 text-center">
<span className="font-black text-amber-700 bg-amber-50 border border-amber-200 px-2.5 py-1 rounded-lg text-[12px]">
{d.maxDiscountPercentage}%
</span>
</td>
<td className="px-2 py-2">
<button
onClick={() => handleRemoveUserDiscount(product.id, d.id)}
className="p-1 text-rose-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors"
>
<Trash2 size={13} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Add / edit cap form */}
<div className="bg-amber-50/40 border border-amber-200 rounded-2xl p-3 space-y-2.5">
<p className="text-[10px] font-black text-amber-700 uppercase tracking-wider flex items-center gap-1.5">
<ShieldCheck size={12} /> Assign Discount Cap
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{/* User picker */}
<select
value={discountFormUserId}
onChange={e => setDiscountFormUserId(e.target.value)}
className="col-span-1 p-2 border border-amber-200 bg-white rounded-xl text-xs font-bold text-gray-700 focus:ring-2 focus:ring-amber-400 outline-none"
>
<option value=""> Select User </option>
{allUsers.map(u => (
<option key={u.id} value={u.id}>
{u.name || u.email} ({u.role.replace(/_/g,' ')})
</option>
))}
</select>
{/* Scheme picker (optional) */}
<select
value={discountFormSchemeId}
onChange={e => setDiscountFormSchemeId(e.target.value)}
className="col-span-1 p-2 border border-amber-200 bg-white rounded-xl text-xs font-bold text-gray-700 focus:ring-2 focus:ring-amber-400 outline-none"
>
<option value="">All Schemes (product-level)</option>
{(product.schemes || []).map(s => (
<option key={s.id} value={s.id}>
{s.name} {s.price.toLocaleString()}
</option>
))}
</select>
{/* Max % input */}
<div className="relative col-span-1">
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-amber-500 font-black text-xs">%</span>
<input
type="number"
onWheel={(e) => e.currentTarget.blur()}
min="0"
max="100"
step="0.01"
value={discountFormValue}
onChange={e => setDiscountFormValue(e.target.value)}
placeholder="Max Discount %"
className="w-full pr-8 pl-3 py-2 border border-amber-200 bg-white rounded-xl text-xs font-bold text-gray-700 focus:ring-2 focus:ring-amber-400 outline-none"
/>
</div>
</div>
<button
onClick={() => handleSaveUserDiscount(product.id)}
disabled={savingDiscount}
className="w-full py-2 bg-amber-500 hover:bg-amber-600 disabled:opacity-60 text-white font-black text-[11px] uppercase tracking-wider rounded-xl transition-all flex items-center justify-center gap-1.5"
>
<CheckCircle2 size={13} />
{savingDiscount ? 'Saving...' : 'Save Discount Cap'}
</button>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}