parent
107126b1f4
commit
bc417200e0
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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">×</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">
|
||||
<p className="text-4xl mb-3">📭</p>
|
||||
<p className="text-gray-500 font-semibold">No activities match these filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
groupByDate(activities).map(([dateLabel, items]) => {
|
||||
const dateObj = new Date(items[0].date);
|
||||
dateObj.setHours(0,0,0,0);
|
||||
const isToday = dateObj.getTime() === today.getTime();
|
||||
const isPast = dateObj < today;
|
||||
return (
|
||||
<div key={dateLabel}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`text-xs font-black px-3 py-1 rounded-full uppercase tracking-wider ${isToday ? 'bg-odoo-primary text-white' : isPast ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{isToday ? '📅 Today' : isPast ? `⚠️ ${dateLabel}` : dateLabel}
|
||||
</div>
|
||||
<div className="flex-1 h-px bg-gray-100"/>
|
||||
<span className="text-xs text-gray-400 font-semibold">{items.length} item{items.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.map(a => (
|
||||
<div key={a.id} className={`flex items-start gap-4 p-4 rounded-xl border transition-all ${a.status === 'DONE' ? 'bg-gray-50 border-gray-100 opacity-70' : isPast && a.status === 'PENDING' ? 'bg-red-50 border-red-200' : 'bg-white border-gray-200 hover:shadow-md'}`}>
|
||||
<div className={`mt-1 flex-shrink-0 p-2 rounded-lg ${a.status === 'DONE' ? 'bg-emerald-100 text-emerald-600' : isPast ? 'bg-red-100 text-red-600' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{getTypeIcon(a.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
{a.client && <span className="text-sm font-bold text-gray-900">{a.client.companyName || a.client.name}</span>}
|
||||
{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>}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-gray-400 mt-2 font-bold uppercase tracking-widest">
|
||||
🕐 {new Date(a.date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex flex-col items-end gap-2">
|
||||
{a.status === 'DONE' ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-bold px-3 py-1 bg-emerald-100 text-emerald-700 rounded-full">
|
||||
<CheckCircle2 size={11}/> Done
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleMarkDone(a)}
|
||||
className="text-xs font-bold px-3 py-1.5 bg-odoo-primary text-white rounded-lg hover:bg-odoo-primary/90 transition-all active:scale-95"
|
||||
>
|
||||
Mark Done
|
||||
</button>
|
||||
)}
|
||||
{isAdminOrGM && a.status !== 'DONE' && (
|
||||
reassigning === a.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={reassignUserId}
|
||||
onChange={e => setReassignUserId(e.target.value)}
|
||||
className="text-xs p-1.5 border border-gray-300 rounded-lg outline-none bg-white"
|
||||
autoFocus
|
||||
>
|
||||
<option value="">User...</option>
|
||||
{users.filter(u => u.id !== a.user?.id).map(u => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleReassign(a.id)}
|
||||
className="text-xs font-bold px-2 py-1.5 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-all"
|
||||
>Go</button>
|
||||
<button
|
||||
onClick={() => { setReassigning(null); setReassignUserId(''); }}
|
||||
className="text-xs px-2 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-100 transition-all"
|
||||
>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setReassigning(a.id); setReassignUserId(''); }}
|
||||
className="text-xs font-semibold px-3 py-1 border border-amber-300 text-amber-700 bg-amber-50 rounded-lg hover:bg-amber-100 transition-all"
|
||||
>
|
||||
↩ Reassign
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div 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>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
(() => {
|
||||
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 (
|
||||
<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="text-[10px] opacity-70 font-bold uppercase mt-0.5">
|
||||
{dateObj.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
{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' && (
|
||||
<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-[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-[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-[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>
|
||||
{newActivity.type === 'QUOTE' ? (
|
||||
{/* 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"
|
||||
</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="">Select Client...</option>
|
||||
{clients.map(c => <option key={c.id} value={c.id}>{c.companyName || c.name}</option>)}
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
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) ||
|
||||
companyName.includes(term) ||
|
||||
contactName.includes(term) ||
|
||||
clientEmail.includes(term);
|
||||
// 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() || '';
|
||||
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());
|
||||
setNewItemData({
|
||||
...newItemData,
|
||||
title: val,
|
||||
value: matchedProduct ? String(matchedProduct.price) : newItemData.value
|
||||
});
|
||||
if (matchedProduct) {
|
||||
setNewItemData({
|
||||
...newItemData,
|
||||
title: val,
|
||||
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>
|
||||
|
||||
<div>
|
||||
<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"
|
||||
value={newItemData.value}
|
||||
onChange={e => setNewItemData({ ...newItemData, value: e.target.value })}
|
||||
/>
|
||||
</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 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>
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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() {
|
|||
}
|
||||
};
|
||||
|
||||
const toggleExpandProduct = (id: string) => {
|
||||
const next = expandedProductId === id ? null : id;
|
||||
setExpandedProductId(next);
|
||||
if (next) {
|
||||
fetchUserDiscounts(next);
|
||||
// reset discount form
|
||||
setDiscountFormUserId('');
|
||||
setDiscountFormSchemeId('');
|
||||
setDiscountFormValue('');
|
||||
}
|
||||
};
|
||||
|
||||
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="odoo-card p-6 mb-8">
|
||||
<h3 className="text-xl font-bold mb-4 text-gray-800">Product Management</h3>
|
||||
|
||||
<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">
|
||||
<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 & Schemes
|
||||
</h1>
|
||||
<p className="text-sm text-white/70 font-medium mt-1">Manage core products, pricing schemes, and feature specs</p>
|
||||
</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 />
|
||||
<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 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'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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-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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 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 & 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>
|
||||
{(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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue