done till 22/05/2026

done till 22/05/2026
main
Manu Krishna 2026-05-22 14:45:47 +05:30
parent 3bb4c35def
commit 9a875aea9f
16 changed files with 1415 additions and 489 deletions

View File

@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ reactStrictMode: false,
}; };
export default nextConfig; export default nextConfig;

View File

@ -3,9 +3,9 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3001", "dev": "next dev -p 3005",
"build": "next build", "build": "next build",
"start": "next start -p 3001", "start": "next start -p 3005",
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {

View File

@ -18,7 +18,9 @@ import ActivitiesManager from '@/components/ActivitiesManager';
import FunnelAnalysisPage from '@/components/FunnelAnalysisPage'; import FunnelAnalysisPage from '@/components/FunnelAnalysisPage';
import CallLogs from '@/components/CallLogs'; import CallLogs from '@/components/CallLogs';
import Settings from '@/components/Settings'; import Settings from '@/components/Settings';
import { LayoutDashboard, Map, Users, Package, LogOut, Menu, UserPlus, DollarSign, FileText, BarChart, TrendingUp, Briefcase, IndianRupee, Target, CalendarCheck, GitMerge, PhoneCall, Settings as SettingsIcon } from 'lucide-react'; import FloatingEventButton from '@/components/FloatingEventButton';
import AdminCalculations from '@/components/AdminCalculations';
import { LayoutDashboard, Map, Users, Package, LogOut, Menu, UserPlus, DollarSign, FileText, BarChart, TrendingUp, Briefcase, IndianRupee, Target, CalendarCheck, GitMerge, PhoneCall, Settings as SettingsIcon, Calculator } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
const LiveMap = dynamic(() => import('@/components/LiveMap'), { const LiveMap = dynamic(() => import('@/components/LiveMap'), {
@ -43,24 +45,31 @@ export default function DashboardPage() {
return <div className="flex justify-center items-center h-screen">Loading...</div>; return <div className="flex justify-center items-center h-screen">Loading...</div>;
} }
const menuItems = [ const userPermissions: string[] = user?.permissions ? JSON.parse(user.permissions) : [];
const allMenuItems = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
...(user?.role === 'ADMIN' || user?.role === 'GENERAL_MANAGER' ? [{ id: 'tracking', label: 'Live Tracking', icon: Map }] : []), { id: 'tracking', label: 'Live Tracking', icon: Map },
{ id: 'opportunities', label: 'Opportunities', icon: Briefcase }, { id: 'opportunities', label: 'Opportunities', icon: Briefcase },
{ id: 'clients', label: 'Clients', icon: Users }, { id: 'clients', label: 'Clients', icon: Users },
{ id: 'quotes', label: 'Quotes', icon: FileText }, { id: 'quotes', label: 'Quotes', icon: FileText },
{ id: 'expenses', label: 'Expenses', icon: IndianRupee }, { id: 'expenses', label: 'Expenses', icon: IndianRupee },
{ id: 'incentives', label: 'Incentives', icon: TrendingUp }, { id: 'incentives', label: 'Incentives', icon: TrendingUp },
{ id: 'reports', label: 'Reports', icon: BarChart }, { id: 'reports', label: 'Reports', icon: BarChart },
...(user?.role === 'ADMIN' || user?.role === 'GENERAL_MANAGER' ? [{ id: 'targets', label: 'Targets', icon: Target }] : []), { id: 'targets', label: 'Targets', icon: Target },
{ id: 'activities', label: 'Activities', icon: CalendarCheck }, { id: 'activities', label: 'Activities', icon: CalendarCheck },
{ id: 'call-logs', label: 'Call Logs', icon: PhoneCall }, { id: 'call-logs', label: 'Call Logs', icon: PhoneCall },
...(user?.role === 'ADMIN' || user?.role === 'GENERAL_MANAGER' ? [{ id: 'funnel-analysis', label: 'Funnel Analysis', icon: GitMerge }] : []), { id: 'funnel-analysis', label: 'Funnel Analysis', icon: GitMerge },
{ id: 'pipeline-engine', label: 'Pipeline Engine', icon: Calculator },
{ id: 'products', label: 'Products', icon: Package }, { id: 'products', label: 'Products', icon: Package },
{ id: 'users', label: 'Users', icon: UserPlus }, { id: 'users', label: 'Users', icon: UserPlus },
{ id: 'settings', label: 'Settings', icon: SettingsIcon }, { id: 'settings', label: 'Settings', icon: SettingsIcon },
]; ];
const menuItems = allMenuItems.filter(item => {
return userPermissions.includes(item.id) || user?.role === 'ADMIN';
});
const renderContent = () => { const renderContent = () => {
switch (activeTab) { switch (activeTab) {
case 'dashboard': case 'dashboard':
@ -108,6 +117,8 @@ export default function DashboardPage() {
return <CallLogs />; return <CallLogs />;
case 'funnel-analysis': case 'funnel-analysis':
return <FunnelAnalysisPage />; return <FunnelAnalysisPage />;
case 'pipeline-engine':
return (user?.role === 'ADMIN' || userPermissions.includes('pipeline-engine')) ? <AdminCalculations /> : <DashboardOverview />;
case 'products': case 'products':
return <ProductManager />; return <ProductManager />;
case 'targets': case 'targets':
@ -216,6 +227,9 @@ export default function DashboardPage() {
<div className="max-w-[1600px] mx-auto min-h-full"> <div className="max-w-[1600px] mx-auto min-h-full">
{renderContent()} {renderContent()}
</div> </div>
{/* Floating Quick Action */}
<FloatingEventButton />
</main> </main>
</div> </div>
</div> </div>

View File

@ -3,22 +3,25 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import api from '../lib/axios'; import api from '../lib/axios';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { Calendar, User, Building2, Filter, CheckCircle2, Clock, AlertTriangle, RefreshCw, Presentation, FileText, MessageSquare, ListTodo } from 'lucide-react'; import { Calendar, User, Building2, Filter, CheckCircle2, Clock, AlertTriangle, RefreshCw, Presentation, FileText, MessageSquare, ListTodo, Phone, Send, MapPin, Handshake, Mail, MessageCircle, ClipboardCheck, FileSearch } from 'lucide-react';
interface Activity { interface Activity {
id: string; id: string;
type: 'FOLLOWUP' | 'DEMO' | 'QUOTE' | 'NEGOTIATION'; type: 'CALL' | 'MESSAGE' | 'DEMO_SCHEDULED' | 'DEMO_COMPLETED' | 'QUOTE_REQUEST' | 'QUOTE_SEND' | 'VISIT_SCHEDULED' | 'VISIT_COMPLETED' | 'NEGOTIATION' | 'FOLLOWUP' | 'DEMO' | 'QUOTE';
notes: string; notes: string;
status: string; status: string;
date: string; date: string;
createdAt: string; createdAt: string;
client?: { id: string; name: string; companyName?: string; files?: any[] }; client?: { id: string; name: string; companyName?: string; files?: any[] };
user?: { id: string; name: string }; user?: { id: string; name: string };
opportunity?: { id: string; title: string };
enquiry?: { id: string; products?: { name: string }[] };
demoPersonName?: string; demoPersonName?: string;
demoContactDetails?: string; demoContactDetails?: string;
keyQueries?: string; keyQueries?: string;
objections?: string; objections?: string;
competitorMention?: string; competitorMention?: string;
stage: 'LEAD' | 'QUALIFIED' | 'POTENTIAL' | 'SALES' | 'CLOSED';
} }
interface FilterState { interface FilterState {
@ -28,9 +31,11 @@ interface FilterState {
dateTo: string; dateTo: string;
status: string; status: string;
type: string; type: string;
opportunityId?: string;
enquiryId?: string;
} }
export default function ActivitiesManager({ initialClientId, initialOpportunityId }: { initialClientId?: string; initialOpportunityId?: string }) { export default function ActivitiesManager({ initialClientId, initialOpportunityId, initialEnquiryId }: { initialClientId?: string; initialOpportunityId?: string; initialEnquiryId?: string }) {
const { user } = useAuth(); const { user } = useAuth();
const [activities, setActivities] = useState<Activity[]>([]); const [activities, setActivities] = useState<Activity[]>([]);
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<any[]>([]);
@ -40,20 +45,30 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
const [reassigning, setReassigning] = useState<string | null>(null); const [reassigning, setReassigning] = useState<string | null>(null);
const [reassignUserId, setReassignUserId] = useState(''); const [reassignUserId, setReassignUserId] = useState('');
const [filters, setFilters] = useState<FilterState>({ const [filters, setFilters] = useState<FilterState>({
userId: '', clientId: initialClientId || '', dateFrom: '', dateTo: '', status: '', type: '' userId: '', clientId: initialClientId || '', dateFrom: '', dateTo: '', status: '', type: '', opportunityId: initialOpportunityId || '', enquiryId: initialEnquiryId || ''
}); });
const [feedbackActivity, setFeedbackActivity] = useState<Activity | null>(null); const [feedbackActivity, setFeedbackActivity] = useState<Activity | null>(null);
const [demoFeedback, setDemoFeedback] = useState({ const [demoFeedback, setDemoFeedback] = useState({
demoPersonName: '', demoPersonName: '',
demoContactDetails: '', demoContactDetails: '',
keyQueries: '', keyQueries: '',
competitorMention: '' competitorMention: '',
customerFeedback: '',
requirementDetails: '',
suggestions: '',
budget: '',
expectedClosingTimeline: '',
competitorInfo: '',
staffRemarks: '',
customerCommitments: '',
caCsDetails: ''
}); });
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [newActivity, setNewActivity] = useState({ const [newActivity, setNewActivity] = useState({
userId: user?.id || '', userId: user?.id || '',
clientId: initialClientId || '', clientId: initialClientId || '',
opportunityId: initialOpportunityId || '', opportunityId: initialOpportunityId || '',
enquiryId: initialEnquiryId || '',
type: 'FOLLOWUP' as Activity['type'], type: 'FOLLOWUP' as Activity['type'],
notes: '', notes: '',
date: new Date().toISOString().split('T')[0], date: new Date().toISOString().split('T')[0],
@ -62,7 +77,8 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
demoContactDetails: '', demoContactDetails: '',
keyQueries: '', keyQueries: '',
objections: '', objections: '',
competitorMention: '' competitorMention: '',
stage: 'LEAD' as const
}); });
useEffect(() => { useEffect(() => {
@ -90,6 +106,8 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
if (f.dateTo) params.append('dateTo', f.dateTo); if (f.dateTo) params.append('dateTo', f.dateTo);
if (f.status) params.append('status', f.status); if (f.status) params.append('status', f.status);
if (f.type) params.append('type', f.type); 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()}`); const res = await api.get(`/followups?${params.toString()}`);
setActivities(res.data); setActivities(res.data);
} catch (e) { } catch (e) {
@ -107,15 +125,29 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
const handleApply = () => fetchActivities(filters); const handleApply = () => fetchActivities(filters);
const handleReset = () => { const handleReset = () => {
const reset: FilterState = { userId: '', clientId: '', dateFrom: '', dateTo: '', status: '', type: '' }; const reset: FilterState = { userId: '', clientId: '', dateFrom: '', dateTo: '', status: '', type: '', opportunityId: '', enquiryId: '' };
setFilters(reset); setFilters(reset);
fetchActivities(reset); fetchActivities(reset);
}; };
const handleMarkDone = async (activity: Activity) => { const handleMarkDone = async (activity: Activity) => {
if (activity.type === 'DEMO') { if (activity.type === 'DEMO' || activity.type === 'DEMO_SCHEDULED' || activity.type === 'VISIT_SCHEDULED' || activity.type === 'DEMO_COMPLETED' || activity.type === 'VISIT_COMPLETED') {
setFeedbackActivity(activity); setFeedbackActivity(activity);
setDemoFeedback({ demoPersonName: '', demoContactDetails: '', keyQueries: '', competitorMention: '' }); setDemoFeedback({
demoPersonName: '',
demoContactDetails: '',
keyQueries: '',
competitorMention: '',
customerFeedback: '',
requirementDetails: '',
suggestions: '',
budget: '',
expectedClosingTimeline: '',
competitorInfo: '',
staffRemarks: '',
customerCommitments: '',
caCsDetails: ''
});
return; return;
} }
@ -132,9 +164,25 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
e.preventDefault(); e.preventDefault();
if (!feedbackActivity) return; if (!feedbackActivity) return;
if (!demoFeedback.demoPersonName || !demoFeedback.demoContactDetails) { const isMandatoryType = ['DEMO_COMPLETED', 'VISIT_COMPLETED', 'DEMO'].includes(feedbackActivity.type);
alert('Please provide Person Met and Contact Details.');
return; if (isMandatoryType) {
const requiredFields = [
'customerFeedback', 'requirementDetails', 'budget',
'expectedClosingTimeline', 'competitorInfo', 'staffRemarks',
'customerCommitments', 'caCsDetails'
];
const missing = requiredFields.filter(f => !demoFeedback[f as keyof typeof demoFeedback]);
if (missing.length > 0) {
alert(`Please fill all mandatory fields: ${missing.join(', ')}`);
return;
}
} else {
// For other types (like scheduled ones marked done), person name is still helpful
if (!demoFeedback.demoPersonName || !demoFeedback.demoContactDetails) {
alert('Please provide Person Met and Contact Details.');
return;
}
} }
try { try {
@ -173,6 +221,7 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
await api.post('/followups', { await api.post('/followups', {
...newActivity, ...newActivity,
opportunityId: newActivity.opportunityId || null, opportunityId: newActivity.opportunityId || null,
enquiryId: newActivity.enquiryId || null,
date: new Date(dateStr).toISOString(), date: new Date(dateStr).toISOString(),
status: 'PENDING' status: 'PENDING'
}); });
@ -181,6 +230,7 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
userId: user?.id || '', userId: user?.id || '',
clientId: initialClientId || '', clientId: initialClientId || '',
opportunityId: initialOpportunityId || '', opportunityId: initialOpportunityId || '',
enquiryId: initialEnquiryId || '',
type: 'FOLLOWUP', type: 'FOLLOWUP',
notes: '', notes: '',
date: new Date().toISOString().split('T')[0], date: new Date().toISOString().split('T')[0],
@ -189,7 +239,8 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
demoContactDetails: '', demoContactDetails: '',
keyQueries: '', keyQueries: '',
objections: '', objections: '',
competitorMention: '' competitorMention: '',
stage: 'LEAD'
}); });
fetchActivities(filters); fetchActivities(filters);
alert('Activity scheduled successfully!'); alert('Activity scheduled successfully!');
@ -219,23 +270,39 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
const getTypeIcon = (type: Activity['type']) => { const getTypeIcon = (type: Activity['type']) => {
switch(type) { switch(type) {
case 'CALL': return <Phone size={14} className="text-green-500" />;
case 'MESSAGE': return <MessageCircle size={14} className="text-cyan-500" />;
case 'DEMO_SCHEDULED': return <Calendar size={14} className="text-blue-500" />;
case 'DEMO_COMPLETED': return <CheckCircle2 size={14} className="text-emerald-500" />;
case 'QUOTE_REQUEST': return <FileSearch size={14} className="text-purple-500" />;
case 'QUOTE_SEND': return <Send size={14} className="text-indigo-500" />;
case 'VISIT_SCHEDULED': return <MapPin size={14} className="text-orange-500" />;
case 'VISIT_COMPLETED': return <ClipboardCheck size={14} className="text-red-500" />;
case 'NEGOTIATION': return <Handshake size={14} className="text-amber-500" />;
case 'DEMO': return <Presentation size={14} className="text-blue-500" />; case 'DEMO': return <Presentation size={14} className="text-blue-500" />;
case 'QUOTE': return <FileText size={14} className="text-purple-500" />; case 'QUOTE': return <FileText size={14} className="text-purple-500" />;
case 'NEGOTIATION': return <MessageSquare size={14} className="text-amber-500" />; default: return <ListTodo size={14} className="text-slate-500" />;
default: return <ListTodo size={14} className="text-indigo-500" />;
} }
}; };
const getTypeBadge = (type: Activity['type']) => { const getTypeBadge = (type: Activity['type']) => {
const styles: Record<string, string> = { const styles: Record<string, string> = {
CALL: 'bg-green-100 text-green-700 border-green-200',
MESSAGE: 'bg-cyan-100 text-cyan-700 border-cyan-200',
DEMO_SCHEDULED: 'bg-blue-100 text-blue-700 border-blue-200',
DEMO_COMPLETED: 'bg-emerald-100 text-emerald-700 border-emerald-200',
QUOTE_REQUEST: 'bg-purple-100 text-purple-700 border-purple-200',
QUOTE_SEND: 'bg-indigo-100 text-indigo-700 border-indigo-200',
VISIT_SCHEDULED: 'bg-orange-100 text-orange-700 border-orange-200',
VISIT_COMPLETED: 'bg-red-100 text-red-700 border-red-200',
NEGOTIATION: 'bg-amber-100 text-amber-700 border-amber-200',
FOLLOWUP: 'bg-slate-100 text-slate-700 border-slate-200',
DEMO: 'bg-blue-100 text-blue-700 border-blue-200', DEMO: 'bg-blue-100 text-blue-700 border-blue-200',
QUOTE: 'bg-purple-100 text-purple-700 border-purple-200', QUOTE: 'bg-purple-100 text-purple-700 border-purple-200',
NEGOTIATION: 'bg-amber-100 text-amber-700 border-amber-200',
FOLLOWUP: 'bg-indigo-100 text-indigo-700 border-indigo-200',
}; };
return ( return (
<span className={`text-[10px] font-black px-2 py-0.5 rounded border ${styles[type] || styles.FOLLOWUP}`}> <span className={`text-[10px] font-black px-2 py-0.5 rounded border uppercase ${styles[type] || styles.FOLLOWUP}`}>
{type} {type.replace('_', ' ')}
</span> </span>
); );
}; };
@ -383,6 +450,8 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1"> <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.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)} {getTypeBadge(a.type)}
{a.user && isAdminOrGM && <span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Assigned to {a.user.name}</span>} {a.user && isAdminOrGM && <span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Assigned to {a.user.name}</span>}
</div> </div>
@ -472,19 +541,25 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Activity Type</label> <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-4 gap-2">
{[ {[
{ id: 'FOLLOWUP', label: 'Follow-up', icon: <ListTodo size={16}/>, color: 'indigo' }, { id: 'CALL', label: 'Call', icon: <Phone size={16}/>, color: 'green' },
{ id: 'DEMO', label: 'Demo', icon: <Presentation size={16}/>, color: 'blue' }, { id: 'MESSAGE', label: 'Msg', icon: <MessageCircle size={16}/>, color: 'cyan' },
{ id: 'QUOTE', label: 'Quote', icon: <FileText size={16}/>, color: 'purple' }, { id: 'DEMO_SCHEDULED', label: 'Demo Sch', icon: <Calendar size={16}/>, color: 'blue' },
{ id: 'NEGOTIATION', label: 'Negotiate', icon: <MessageSquare size={16}/>, color: 'amber' }, { id: 'DEMO_COMPLETED', label: 'Demo Done', icon: <CheckCircle2 size={16}/>, color: 'emerald' },
{ 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: 'VISIT_SCHEDULED', label: 'Visit Sch', icon: <MapPin size={16}/>, color: 'orange' },
{ id: 'VISIT_COMPLETED', label: 'Visit Done', icon: <ClipboardCheck size={16}/>, color: 'red' },
{ id: 'NEGOTIATION', label: 'Negotiate', icon: <Handshake size={16}/>, color: 'amber' },
{ id: 'FOLLOWUP', label: 'Other', icon: <ListTodo size={16}/>, color: 'slate' },
].map(t => ( ].map(t => (
<button <button
key={t.id} key={t.id}
type="button" type="button"
onClick={() => setNewActivity({ ...newActivity, type: t.id as any })} onClick={() => setNewActivity({ ...newActivity, type: t.id as any })}
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${newActivity.type === t.id ? `border-${t.color}-500 bg-${t.color}-50 text-${t.color}-700` : 'border-gray-100 bg-gray-50 text-gray-500 hover:border-gray-200'}`} 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} {t.icon}
<span className="text-[10px] font-black mt-1 uppercase tracking-tight">{t.label}</span> <span className="text-[9px] font-black mt-1 uppercase tracking-tight">{t.label}</span>
</button> </button>
))} ))}
</div> </div>
@ -543,6 +618,22 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
</select> </select>
</div> </div>
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Stage *</label>
<select
required
value={newActivity.stage}
onChange={e => setNewActivity({ ...newActivity, stage: e.target.value as any })}
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="LEAD">LEAD</option>
<option value="QUALIFIED">QUALIFIED</option>
<option value="POTENTIAL">POTENTIAL</option>
<option value="SALES">SALES</option>
<option value="CLOSED">CLOSED</option>
</select>
</div>
<div> <div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Date *</label> <label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Date *</label>
<input <input
@ -601,50 +692,126 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-[9999] backdrop-blur-sm transition-all duration-300"> <div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-[9999] backdrop-blur-sm transition-all duration-300">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden flex flex-col max-h-[90vh]"> <div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden flex flex-col max-h-[90vh]">
<div className="bg-odoo-primary px-6 py-4 flex justify-between items-center text-white shrink-0"> <div className="bg-odoo-primary px-6 py-4 flex justify-between items-center text-white shrink-0">
<h3 className="font-bold text-lg">Demo Feedback</h3> <h3 className="font-bold text-lg">Activity Feedback</h3>
<button onClick={() => setFeedbackActivity(null)} className="hover:bg-white/20 p-1.5 rounded-lg transition-colors"></button> <button onClick={() => setFeedbackActivity(null)} className="hover:bg-white/20 p-1.5 rounded-lg transition-colors"></button>
</div> </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 shrink-0">
<div> <div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Person Met *</label> <label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Customer Feedback *</label>
<input
type="text" required
value={demoFeedback.demoPersonName}
onChange={e => setDemoFeedback({ ...demoFeedback, demoPersonName: e.target.value })}
className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm"
placeholder="e.g. John Doe (CTO)"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Contact Details *</label>
<input
type="text" required
value={demoFeedback.demoContactDetails}
onChange={e => setDemoFeedback({ ...demoFeedback, demoContactDetails: e.target.value })}
className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm"
placeholder="Phone or Email"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Key Queries</label>
<textarea <textarea
value={demoFeedback.keyQueries} required
onChange={e => setDemoFeedback({ ...demoFeedback, keyQueries: e.target.value })} value={demoFeedback.customerFeedback}
onChange={e => setDemoFeedback({ ...demoFeedback, customerFeedback: 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" className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm resize-none"
rows={3} placeholder="What did they ask about?" rows={2} placeholder="Customer's reaction/feedback"
/> />
</div> </div>
<div> <div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Competitor Mentioned</label> <label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Requirement Details *</label>
<input <textarea
type="text" required
value={demoFeedback.competitorMention} value={demoFeedback.requirementDetails}
onChange={e => setDemoFeedback({ ...demoFeedback, competitorMention: e.target.value })} onChange={e => setDemoFeedback({ ...demoFeedback, requirementDetails: 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" 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"
placeholder="e.g. Salesforce, Zoho" rows={2} placeholder="What are their specific needs?"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Budget *</label>
<input
type="text" required
value={demoFeedback.budget}
onChange={e => setDemoFeedback({ ...demoFeedback, budget: e.target.value })}
className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm"
placeholder="e.g. 5-10 Lacs"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Expected Timeline *</label>
<input
type="text" required
value={demoFeedback.expectedClosingTimeline}
onChange={e => setDemoFeedback({ ...demoFeedback, expectedClosingTimeline: e.target.value })}
className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm"
placeholder="e.g. 2 weeks"
/>
</div>
</div>
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Competitor Info *</label>
<input
type="text" required
value={demoFeedback.competitorInfo}
onChange={e => setDemoFeedback({ ...demoFeedback, competitorInfo: e.target.value })}
className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm"
placeholder="Which other vendors are they considering?"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Staff Remarks *</label>
<textarea
required
value={demoFeedback.staffRemarks}
onChange={e => setDemoFeedback({ ...demoFeedback, staffRemarks: e.target.value })}
className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm resize-none"
rows={2} placeholder="Internal observations"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Customer Commitments *</label>
<textarea
required
value={demoFeedback.customerCommitments}
onChange={e => setDemoFeedback({ ...demoFeedback, customerCommitments: e.target.value })}
className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm resize-none"
rows={2} placeholder="What did the customer promise?"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">CA / CS Details *</label>
<input
type="text" required
value={demoFeedback.caCsDetails}
onChange={e => setDemoFeedback({ ...demoFeedback, caCsDetails: e.target.value })}
className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm"
placeholder="Chartered Accountant / Company Secretary"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Suggestions</label>
<textarea
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"
rows={2} placeholder="Any suggestions for product/service?"
/>
</div>
<hr className="border-gray-100" />
<p className="text-[10px] text-gray-400 font-bold uppercase italic">Initial Demo Fields (Optional)</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Person Met</label>
<input
type="text"
value={demoFeedback.demoPersonName}
onChange={e => setDemoFeedback({ ...demoFeedback, demoPersonName: e.target.value })}
className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm"
/>
</div>
<div>
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Contact Details</label>
<input
type="text"
value={demoFeedback.demoContactDetails}
onChange={e => setDemoFeedback({ ...demoFeedback, demoContactDetails: e.target.value })}
className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm"
/>
</div>
</div>
<button type="submit" className="w-full bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-3 px-4 rounded-xl shadow-lg shadow-emerald-500/30 transition-all mt-4"> <button type="submit" className="w-full bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-3 px-4 rounded-xl shadow-lg shadow-emerald-500/30 transition-all mt-4">
Submit & Mark Done Submit & Mark Done

View File

@ -0,0 +1,297 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Target, TrendingUp, Calculator, Activity, Package, Briefcase, ChevronRight, DollarSign, ArrowRightCircle, Users, CheckCircle2 } from 'lucide-react';
import api from '../lib/axios';
const ROLE_TARGETS = {
TELESALES_EXECUTIVE: {
calls: 60,
qualified: 20,
potential: 5,
demo: 2,
visit: 1,
revenue: 400000,
requiredPipeline: { LEAD: 20000000, QUALIFIED: 8000000, POTENTIAL: 3200000, DEMO_VISIT: 2600000, CLOSED: 400000 }
},
OFFICER: {
calls: 0,
qualified: 15,
potential: 5,
demo: 2,
visit: 2,
revenue: 500000,
requiredPipeline: { LEAD: 25000000, QUALIFIED: 10000000, POTENTIAL: 4000000, DEMO_VISIT: 3250000, CLOSED: 500000 }
},
MANAGER: {
calls: 0,
qualified: 0,
potential: 5,
demo: 3,
visit: 2,
revenue: 800000,
requiredPipeline: { LEAD: 0, QUALIFIED: 0, POTENTIAL: 6400000, DEMO_VISIT: 5200000, CLOSED: 800000 }
},
GENERAL_MANAGER: {
calls: 0,
qualified: 0,
potential: 5,
demo: 3,
visit: 2,
revenue: 1000000,
requiredPipeline: { LEAD: 0, QUALIFIED: 0, POTENTIAL: 8000000, DEMO_VISIT: 6500000, CLOSED: 1000000 }
}
};
export default function AdminCalculations() {
const [users, setUsers] = useState<any[]>([]);
const [opportunities, setOpportunities] = useState<any[]>([]);
const [followups, setFollowups] = useState<any[]>([]);
const [targets, setTargets] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
api.get('/users'),
api.get('/opportunities'),
api.get('/followups'),
api.get('/targets')
]).then(([uRes, oRes, fRes, tRes]) => {
setUsers(uRes.data);
setOpportunities(oRes.data);
setFollowups(fRes.data);
setTargets(tRes.data || []);
setLoading(false);
}).catch(err => {
console.error(err);
setLoading(false);
});
}, []);
if (loading) {
return <div className="flex justify-center items-center h-screen text-gray-500 font-bold text-xl">Loading Live Data Engine...</div>;
}
// Only show users who have been explicitly assigned a target in the database
const usersWithTargets = new Set(targets.map((t: any) => t.userId));
const salesUsers = users.filter(u =>
['TELESALES_EXECUTIVE', 'OFFICER', 'MANAGER', 'GENERAL_MANAGER'].includes(u.role) &&
usersWithTargets.has(u.id)
);
// Organization Expected Revenue (Live)
const totalExpectedRevenue = opportunities.reduce((acc, opp) => {
return acc + (Number(opp.value || 0) * (Number(opp.closingProbability || 0) / 100));
}, 0);
const totalPipelineValue = opportunities.reduce((acc, opp) => acc + Number(opp.value || 0), 0);
const today = new Date().toISOString().split('T')[0];
const todayFollowups = followups.filter(f => new Date(f.date).toISOString().split('T')[0] === today || new Date(f.createdAt).toISOString().split('T')[0] === today);
const getUserStats = (userId: string, role: string) => {
const userFollowups = todayFollowups.filter(f => f.userId === userId);
const userOpps = opportunities.filter(o => o.assignedTo === userId);
const actuals = {
calls: userFollowups.filter(f => f.type === 'CALL').length,
demo: userFollowups.filter(f => f.type === 'DEMO_COMPLETED' || f.type === 'DEMO').length,
visit: userFollowups.filter(f => f.type === 'VISIT_COMPLETED' || f.type === 'VISIT_SCHEDULED').length,
qualifiedOpp: userOpps.filter(o => o.stage === 'QUALIFIED').length,
potentialOpp: userOpps.filter(o => o.stage === 'POTENTIAL').length,
};
const defaultTarget = ROLE_TARGETS[role as keyof typeof ROLE_TARGETS] || ROLE_TARGETS.TELESALES_EXECUTIVE;
// Find latest active target
const userTargets = targets.filter((t: any) => t.userId === userId);
const activeTarget = userTargets.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
let target = defaultTarget;
if (activeTarget) {
const revenue = activeTarget.monthlyTarget || defaultTarget.revenue;
target = {
calls: activeTarget.dailyLeadTarget ?? defaultTarget.calls,
qualified: activeTarget.requiredQualityLeads ?? defaultTarget.qualified,
potential: activeTarget.requiredPotential ?? defaultTarget.potential,
demo: activeTarget.requiredDemos ?? defaultTarget.demo,
visit: defaultTarget.visit, // Fallback since visit is not explicitly in target model
revenue: revenue,
requiredPipeline: {
LEAD: revenue * 50,
QUALIFIED: revenue * 20,
POTENTIAL: revenue * 8,
DEMO_VISIT: revenue * 6.5,
CLOSED: revenue
}
};
}
const pipelineValue = {
LEAD: userOpps.filter(o => o.stage === 'LEAD').reduce((sum, o) => sum + o.value, 0),
QUALIFIED: userOpps.filter(o => o.stage === 'QUALIFIED').reduce((sum, o) => sum + o.value, 0),
POTENTIAL: userOpps.filter(o => o.stage === 'POTENTIAL').reduce((sum, o) => sum + o.value, 0),
DEMO_VISIT: userOpps.filter(o => o.stage === 'SALES').reduce((sum, o) => sum + o.value, 0),
CLOSED: userOpps.filter(o => o.status === 'CLOSED' || (o.client && o.client.status === 'CLOSED')).reduce((sum, o) => sum + o.value, 0)
};
return { actuals, target, pipelineValue, userOpps };
};
const formatInr = (val: number) => `${(val / 100000).toFixed(1)}L`;
return (
<div className="p-8 max-w-[1600px] mx-auto space-y-8 font-sans pb-24">
<div className="flex flex-col gap-2 mb-8">
<h1 className="text-4xl font-black text-gray-900 tracking-tight">Live Pipeline Engine</h1>
<p className="text-gray-500 font-medium text-lg">Real-time performance tracking based on actual database metrics</p>
</div>
{/* Live Expected Revenue */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<div className="bg-slate-900 rounded-3xl p-8 text-white shadow-xl relative overflow-hidden">
<div className="absolute top-0 right-0 p-8 opacity-10 pointer-events-none">
<DollarSign size={150} />
</div>
<div className="relative z-10">
<p className="text-emerald-400 font-bold uppercase tracking-widest text-sm mb-2">Live Expected Revenue</p>
<h2 className="text-5xl font-black mb-4">{totalExpectedRevenue.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</h2>
<p className="text-slate-400 text-sm font-medium">Calculated dynamically: Deal Value × Closing Probability</p>
</div>
</div>
<div className="bg-white border border-gray-100 rounded-3xl p-8 shadow-xl relative overflow-hidden">
<p className="text-blue-500 font-bold uppercase tracking-widest text-sm mb-2">Total Gross Pipeline</p>
<h2 className="text-5xl font-black text-gray-900 mb-4">{totalPipelineValue.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</h2>
<p className="text-gray-500 text-sm font-medium">Gross value of all active opportunities in DB</p>
</div>
<div className="bg-white border border-gray-100 rounded-3xl p-8 shadow-xl relative overflow-hidden flex flex-col justify-center">
<div className="bg-slate-50 border border-slate-200 rounded-2xl p-4 shadow-inner text-center">
<span className="text-lg font-black text-slate-800 font-mono">
Expected = Deal Value × Closing Probability
</span>
<div className="mt-2 text-xs font-bold text-slate-400 uppercase tracking-wider">Core Formula</div>
</div>
</div>
</div>
<h2 className="text-2xl font-black text-gray-900 mb-6 flex items-center gap-3 border-b pb-4">
<Users className="text-odoo-primary" />
Live Team Performance vs Targets
</h2>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-8">
{salesUsers.map(user => {
const stats = getUserStats(user.id, user.role);
const { actuals, target, pipelineValue } = stats;
return (
<div key={user.id} className="bg-white rounded-3xl border border-gray-200 shadow-lg overflow-hidden flex flex-col">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-100 flex justify-between items-center">
<div>
<h3 className="font-black text-xl text-gray-900">{user.name}</h3>
<p className="text-xs font-bold text-odoo-primary uppercase tracking-widest">{user.role.replace('_', ' ')}</p>
</div>
<div className="text-right">
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Target Revenue</p>
<p className="font-black text-gray-800">{target.revenue.toLocaleString('en-IN')}</p>
</div>
</div>
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-8 flex-1">
{/* Activity Tracking (Today) */}
<div>
<h4 className="font-bold text-gray-500 uppercase tracking-wider text-xs mb-4 flex items-center gap-2">
<Activity size={14}/> Today's Activities
</h4>
<div className="space-y-3">
{target.calls > 0 && (
<div>
<div className="flex justify-between text-sm font-bold mb-1">
<span className="text-gray-700">Calls</span>
<span className={actuals.calls >= target.calls ? "text-emerald-600" : "text-amber-600"}>{actuals.calls} / {target.calls}</span>
</div>
<div className="h-2 w-full bg-gray-100 rounded-full overflow-hidden">
<div className={`h-full ${actuals.calls >= target.calls ? 'bg-emerald-500' : 'bg-amber-500'}`} style={{ width: `${Math.min((actuals.calls / target.calls) * 100, 100)}%` }}></div>
</div>
</div>
)}
{target.demo > 0 && (
<div>
<div className="flex justify-between text-sm font-bold mb-1">
<span className="text-gray-700">Demos</span>
<span className={actuals.demo >= target.demo ? "text-emerald-600" : "text-amber-600"}>{actuals.demo} / {target.demo}</span>
</div>
<div className="h-2 w-full bg-gray-100 rounded-full overflow-hidden">
<div className={`h-full ${actuals.demo >= target.demo ? 'bg-emerald-500' : 'bg-amber-500'}`} style={{ width: `${Math.min((actuals.demo / target.demo) * 100, 100)}%` }}></div>
</div>
</div>
)}
{target.visit > 0 && (
<div>
<div className="flex justify-between text-sm font-bold mb-1">
<span className="text-gray-700">Visits</span>
<span className={actuals.visit >= target.visit ? "text-emerald-600" : "text-amber-600"}>{actuals.visit} / {target.visit}</span>
</div>
<div className="h-2 w-full bg-gray-100 rounded-full overflow-hidden">
<div className={`h-full ${actuals.visit >= target.visit ? 'bg-emerald-500' : 'bg-amber-500'}`} style={{ width: `${Math.min((actuals.visit / target.visit) * 100, 100)}%` }}></div>
</div>
</div>
)}
{target.qualified > 0 && (
<div className="pt-2 border-t border-gray-100">
<div className="flex justify-between text-sm font-bold text-gray-700">
<span>Qualified Opps</span>
<span className={actuals.qualifiedOpp >= target.qualified ? "text-emerald-600" : "text-amber-600"}>{actuals.qualifiedOpp} / {target.qualified}</span>
</div>
</div>
)}
{target.potential > 0 && (
<div className="pt-2 border-t border-gray-100">
<div className="flex justify-between text-sm font-bold text-gray-700">
<span>Potential Opps</span>
<span className={actuals.potentialOpp >= target.potential ? "text-emerald-600" : "text-amber-600"}>{actuals.potentialOpp} / {target.potential}</span>
</div>
</div>
)}
</div>
</div>
{/* Pipeline Tracking */}
<div>
<h4 className="font-bold text-gray-500 uppercase tracking-wider text-xs mb-4 flex items-center gap-2">
<Target size={14}/> Pipeline Actual vs Target
</h4>
<div className="space-y-2">
{Object.entries(target.requiredPipeline).map(([stage, reqValue]) => {
if (reqValue === 0) return null;
const actualVal = pipelineValue[stage as keyof typeof pipelineValue] || 0;
const isMeetingTarget = actualVal >= reqValue;
return (
<div key={stage} className="flex flex-col bg-gray-50 rounded-lg p-2 border border-gray-100">
<div className="flex justify-between items-center text-xs font-bold text-gray-700 mb-1">
<span>{stage.replace('_', ' ')}</span>
<span className={isMeetingTarget ? "text-emerald-600" : "text-rose-500"}>
{formatInr(actualVal)} <span className="text-gray-400 font-medium">/ {formatInr(reqValue)}</span>
</span>
</div>
<div className="h-1.5 w-full bg-gray-200 rounded-full overflow-hidden">
<div className={`h-full ${isMeetingTarget ? 'bg-emerald-500' : 'bg-rose-500'}`} style={{ width: `${Math.min((actualVal / reqValue) * 100, 100)}%` }}></div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -157,7 +157,7 @@ export default function CallLogsPage() {
const statusConverted = meta.convertedToStatus; const statusConverted = meta.convertedToStatus;
const STATUS_STYLES: any = { const STATUS_STYLES: any = {
QUALITY: { bg: 'bg-green-100', text: 'text-green-700', border: 'border-green-200', label: 'Quality Lead' }, QUALIFIED: { bg: 'bg-green-100', text: 'text-green-700', border: 'border-green-200', label: 'Qualified Lead' },
POTENTIAL: { bg: 'bg-yellow-100', text: 'text-yellow-700', border: 'border-yellow-200', label: 'Potential' }, POTENTIAL: { bg: 'bg-yellow-100', text: 'text-yellow-700', border: 'border-yellow-200', label: 'Potential' },
DEMO: { bg: 'bg-purple-100', text: 'text-purple-700', border: 'border-purple-200', label: 'Demo' }, DEMO: { bg: 'bg-purple-100', text: 'text-purple-700', border: 'border-purple-200', label: 'Demo' },
SALES: { bg: 'bg-sky-100', text: 'text-sky-700', border: 'border-sky-200', label: 'Sales' }, SALES: { bg: 'bg-sky-100', text: 'text-sky-700', border: 'border-sky-200', label: 'Sales' },

View File

@ -5,6 +5,7 @@ import api from '../lib/axios';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { UserPlus, Edit2, Search, ArrowLeft, Calendar, FileText, MapPin, Download } from 'lucide-react'; import { UserPlus, Edit2, Search, ArrowLeft, Calendar, FileText, MapPin, Download } from 'lucide-react';
import ClientModal from './ClientModal'; import ClientModal from './ClientModal';
import ActivitiesManager from './ActivitiesManager';
interface Client { interface Client {
id?: string; id?: string;
@ -29,18 +30,33 @@ interface Enquiry {
createdAt: string; createdAt: string;
products: { name: string }[]; products: { name: string }[];
conversation: string; conversation: string;
status: string;
quotes?: { id: string, totalAmount: number, status: string, pdfUrl?: string, createdAt: string }[]; quotes?: { id: string, totalAmount: number, status: string, pdfUrl?: string, createdAt: string }[];
} }
interface Opportunity {
id: string;
title: string;
value: number;
stage: string;
clientId: string;
expectedCloseDate?: string;
createdAt: string;
}
interface Followup { interface Followup {
id: string; id: string;
notes: string; notes: string;
status: string; status: string;
date: string; date: string;
createdAt: string; createdAt: string;
enquiryId?: string;
opportunityId?: string;
} }
const STATUS_OPTIONS = ['LEAD', 'QUALITY', 'POTENTIAL', 'SALES', 'CLOSED']; const STATUS_OPTIONS = ['LEAD', 'QUALIFIED', 'POTENTIAL', 'SALES', 'CLOSED'];
const OPP_STATUS_OPTIONS = ['LEAD', 'QUALIFIED', 'POTENTIAL', 'SALES', 'LOST'];
const ENQ_STATUS_OPTIONS = ['OPEN', 'CLOSED'];
export default function ClientList() { export default function ClientList() {
const { user } = useAuth(); const { user } = useAuth();
@ -48,17 +64,16 @@ export default function ClientList() {
const [filteredClients, setFilteredClients] = useState<Client[]>([]); const [filteredClients, setFilteredClients] = useState<Client[]>([]);
const [selectedClient, setSelectedClient] = useState<Client | null>(null); const [selectedClient, setSelectedClient] = useState<Client | null>(null);
const [enquiries, setEnquiries] = useState<Enquiry[]>([]); const [enquiries, setEnquiries] = useState<Enquiry[]>([]);
const [followups, setFollowups] = useState<Followup[]>([]); const [opportunities, setOpportunities] = useState<Opportunity[]>([]);
const [selectedProductOrOpportunity, setSelectedProductOrOpportunity] = useState<{
id: string;
type: 'enquiry' | 'opportunity';
name: string;
status: string;
} | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<any[]>([]);
// Followup Form
const [newFollowup, setNewFollowup] = useState('');
const [followupDate, setFollowupDate] = useState('');
const [followupStatus, setFollowupStatus] = useState('PENDING');
const [assignedUserId, setAssignedUserId] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// Modal State // Modal State
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [editingClient, setEditingClient] = useState<Client | null>(null); const [editingClient, setEditingClient] = useState<Client | null>(null);
@ -125,62 +140,24 @@ export default function ClientList() {
const handleClientClick = async (client: Client) => { const handleClientClick = async (client: Client) => {
setSelectedClient(client); setSelectedClient(client);
setEnquiries([]); setEnquiries([]);
setFollowups([]); // Clear previous setOpportunities([]);
setSelectedProductOrOpportunity(null);
try { try {
// Fetch Enquiries // Fetch Enquiries
const enqRes = await api.get('/enquiries'); const enqRes = await api.get('/enquiries');
const ClientEnquiries = enqRes.data.filter((e: any) => e.clientId === client.id); const ClientEnquiries = enqRes.data.filter((e: any) => e.clientId === client.id);
setEnquiries(ClientEnquiries); setEnquiries(ClientEnquiries);
// Fetch Followups // Fetch Opportunities
// Ideally backend supports filtering, but filtering client-side for now as per established pattern const oppRes = await api.get('/opportunities');
const followRes = await api.get('/followups'); const clientOpps = oppRes.data.filter((o: any) => o.clientId === client.id);
const clientFollowups = followRes.data.filter((f: any) => f.clientId === client.id); setOpportunities(clientOpps);
// Sort by date desc
clientFollowups.sort((a: Followup, b: Followup) => new Date(b.date).getTime() - new Date(a.date).getTime());
setFollowups(clientFollowups);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
}; };
const handleAddFollowup = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedClient) return;
if (!user || !user.id) {
alert('User not authenticated');
return;
}
setIsSubmitting(true);
try {
const response = await api.post('/followups', {
clientId: selectedClient.id,
notes: newFollowup,
date: new Date(followupDate).toISOString(),
status: followupStatus,
userId: assignedUserId || user.id
});
// Optimistic Update or direct append
// Assuming backend returns the created object
const createdFollowup = response.data;
setFollowups([createdFollowup, ...followups]);
setNewFollowup('');
setFollowupDate('');
setFollowupStatus('PENDING');
setAssignedUserId('');
// alert('Follow-up added'); // Removed annoying alert
} catch (error) {
console.error(error);
alert('Failed to add follow-up');
} finally {
setIsSubmitting(false);
}
};
const handleStatusUpdate = async (newStatus: string) => { const handleStatusUpdate = async (newStatus: string) => {
if (!selectedClient) return; if (!selectedClient) return;
try { try {
@ -199,6 +176,49 @@ export default function ClientList() {
} }
}; };
const handleOpportunityStageUpdate = async (opportunityId: string, newStage: string) => {
try {
const response = await api.patch(`/opportunities/${opportunityId}`, { stage: newStage });
const updatedOpp = response.data;
// Update local opportunities state
setOpportunities(opportunities.map(o => o.id === updatedOpp.id ? updatedOpp : o));
// Update selected product/opportunity status
if (selectedProductOrOpportunity && selectedProductOrOpportunity.id === opportunityId) {
setSelectedProductOrOpportunity({
...selectedProductOrOpportunity,
status: updatedOpp.stage
});
}
} catch (error: any) {
console.error("Failed to update opportunity stage", error);
const msg = error.response?.data?.message || "Failed to update opportunity stage";
alert(msg);
}
};
const handleEnquiryStatusUpdate = async (enquiryId: string, newStatus: string) => {
try {
const response = await api.patch(`/enquiries/${enquiryId}`, { status: newStatus });
const updatedEnq = response.data;
// Update local enquiries state
setEnquiries(enquiries.map(e => e.id === updatedEnq.id ? { ...e, status: updatedEnq.status } : e));
// Update selected product/opportunity status
if (selectedProductOrOpportunity && selectedProductOrOpportunity.id === enquiryId) {
setSelectedProductOrOpportunity({
...selectedProductOrOpportunity,
status: updatedEnq.status
});
}
} catch (error: any) {
console.error("Failed to update enquiry status", error);
alert("Failed to update status");
}
};
const handleSaveClient = async (clientData: any) => { const handleSaveClient = async (clientData: any) => {
try { try {
if (clientData.id) { if (clientData.id) {
@ -230,24 +250,41 @@ export default function ClientList() {
} }
}; };
const handleFollowupStatusToggle = async (followup: Followup) => {
if (followup.status !== 'PENDING') return;
const confirmUpdate = window.confirm("Mark this task as DONE?");
if (!confirmUpdate) return;
try { const getUnifiedProductsAndOpportunities = () => {
const response = await api.patch(`/followups/${followup.id}`, { status: 'DONE' }); const list: any[] = [];
const updatedFollowup = response.data;
// Update state opportunities.forEach(opp => {
setFollowups(followups.map(f => f.id === updatedFollowup.id ? updatedFollowup : f)); list.push({
} catch (error) { id: opp.id,
console.error("Failed to update followup status", error); type: 'opportunity',
alert("Failed to update status"); name: opp.title,
} status: opp.stage,
value: opp.value,
date: opp.createdAt,
raw: opp
});
});
enquiries.forEach(enq => {
const prodNames = enq.products?.map((p: any) => p.name).join(', ') || 'General Enquiry';
list.push({
id: enq.id,
type: 'enquiry',
name: prodNames,
status: enq.status,
value: null,
date: enq.createdAt,
raw: enq
});
});
return list.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}; };
return ( return (
<div className="bg-white shadow-xl rounded-2xl overflow-hidden border border-gray-100 h-[calc(100vh-100px)] flex flex-col"> <div className="bg-white shadow-xl rounded-2xl overflow-hidden border border-gray-100 h-[calc(100vh-100px)] flex flex-col">
{/* Header */} {/* Header */}
@ -325,7 +362,7 @@ export default function ClientList() {
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border <span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border
${client.status === 'SALES' ? 'bg-odoo-secondary/10 text-odoo-secondary border-odoo-secondary/20' : ${client.status === 'SALES' ? 'bg-odoo-secondary/10 text-odoo-secondary border-odoo-secondary/20' :
client.status === 'LEAD' ? 'bg-odoo-primary/10 text-odoo-primary border-odoo-primary/20' : client.status === 'LEAD' ? 'bg-odoo-primary/10 text-odoo-primary border-odoo-primary/20' :
client.status === 'QUALITY' ? 'bg-amber-50 text-amber-700 border-amber-200' : client.status === 'QUALIFIED' ? 'bg-amber-50 text-amber-700 border-amber-200' :
client.status === 'POTENTIAL' ? 'bg-blue-50 text-blue-700 border-blue-200' : client.status === 'POTENTIAL' ? 'bg-blue-50 text-blue-700 border-blue-200' :
'bg-gray-100 text-gray-800 border-gray-200'}`}> 'bg-gray-100 text-gray-800 border-gray-200'}`}>
{client.status} {client.status}
@ -369,112 +406,69 @@ export default function ClientList() {
)} )}
</div> </div>
<div className="mt-4 md:mt-0"> <div className="mt-4 md:mt-0">
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Current Status</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">
<div className="flex bg-white rounded-lg shadow-sm border border-gray-200 p-1"> {selectedProductOrOpportunity
{STATUS_OPTIONS.map((status) => ( ? `Status: ${selectedProductOrOpportunity.name}`
<button : 'Current Status'}
key={status} </label>
onClick={(e) => { e.stopPropagation(); handleStatusUpdate(status); }} {selectedProductOrOpportunity ? (
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${selectedClient.status === status <div className="flex bg-white rounded-lg shadow-sm border border-gray-200 p-1">
? 'bg-odoo-primary text-white shadow' {selectedProductOrOpportunity.type === 'opportunity' ? (
: 'text-gray-600 hover:bg-gray-100' OPP_STATUS_OPTIONS.map((status) => (
}`} <button
> key={status}
{status} onClick={(e) => {
</button> e.stopPropagation();
))} handleOpportunityStageUpdate(selectedProductOrOpportunity.id, status);
</div> }}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all ${
selectedProductOrOpportunity.status === status
? 'bg-emerald-600 text-white shadow'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
{status}
</button>
))
) : (
ENQ_STATUS_OPTIONS.map((status) => (
<button
key={status}
onClick={(e) => {
e.stopPropagation();
handleEnquiryStatusUpdate(selectedProductOrOpportunity.id, status);
}}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all ${
selectedProductOrOpportunity.status === status
? 'bg-indigo-600 text-white shadow'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
{status}
</button>
))
)}
</div>
) : (
<div className="text-sm text-gray-400 italic bg-gray-50 border border-dashed border-gray-200 px-4 py-2 rounded-lg max-w-[320px]">
Select a product/opportunity below to manage status
</div>
)}
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Col: Activity Timeline (Followups) */} {/* Left Col: Activities Manager */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 flex flex-col h-[750px]">
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden"> {selectedClient && selectedClient.id && (
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50 flex justify-between items-center"> <ActivitiesManager
<h3 className="font-bold text-gray-800">Follow-up Timeline</h3> key={`${selectedClient.id}-${selectedProductOrOpportunity?.id || 'all'}`}
<span className="text-xs font-medium bg-odoo-primary/10 text-odoo-primary px-2 py-0.5 rounded-full">{followups.length} Records</span> initialClientId={selectedClient.id}
</div> initialOpportunityId={selectedProductOrOpportunity?.type === 'opportunity' ? selectedProductOrOpportunity.id : undefined}
initialEnquiryId={selectedProductOrOpportunity?.type === 'enquiry' ? selectedProductOrOpportunity.id : undefined}
<div className="p-6 bg-gray-50 border-b border-gray-100"> />
<form onSubmit={handleAddFollowup} className="flex flex-col gap-3"> )}
<textarea
placeholder="Write a note about this interaction..."
value={newFollowup}
onChange={e => setNewFollowup(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-odoo-primary focus:border-odoo-primary outline-none text-sm shadow-sm bg-white"
rows={2}
required
/>
<div className="flex flex-wrap gap-3 items-center">
<input
type="datetime-local"
value={followupDate}
onChange={e => setFollowupDate(e.target.value)}
className="flex-1 min-w-[200px] p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white"
required
/>
<select
value={followupStatus}
onChange={e => setFollowupStatus(e.target.value)}
className="p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white"
>
<option value="DONE">Done (Log)</option>
<option value="PENDING">Pending (Task)</option>
</select>
{(user?.role === 'ADMIN' || user?.role === 'GENERAL_MANAGER') && (
<select
value={assignedUserId}
onChange={e => setAssignedUserId(e.target.value)}
className="p-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-odoo-primary outline-none bg-white"
>
<option value="">Self (Assign to Me)</option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
)}
<button
type="submit"
disabled={isSubmitting}
className="bg-odoo-primary hover:bg-odoo-primary/90 text-white px-6 py-2 rounded-lg text-sm font-semibold shadow-sm transition-all disabled:opacity-50"
>
{isSubmitting ? 'Adding...' : 'Add Note'}
</button>
</div>
</form>
</div>
<div className="max-h-[500px] overflow-y-auto p-6 space-y-6">
{followups.length === 0 ? (
<div className="text-center py-10 text-gray-400">
<p>No follow-up history yet.</p>
</div>
) : (
followups.map((f, index) => (
<div key={f.id} className="relative pl-6 border-l-2 border-gray-200 last:border-0 pb-2">
<div className={`absolute -left-[9px] top-0 h-4 w-4 rounded-full border-4 border-white shadow-sm ${f.status === 'DONE' ? 'bg-odoo-secondary' : 'bg-yellow-400'}`}></div>
<div className="bg-white p-4 rounded-lg border border-gray-100 shadow-sm hover:shadow-md transition-shadow">
<p className="text-gray-800 text-sm whitespace-pre-wrap">{f.notes || (f as any).description}</p>
<div className="flex justify-between items-center mt-3 pt-2 border-t border-gray-50">
<span className="text-xs font-semibold text-odoo-primary">
📅 {new Date(f.date).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}
</span>
<button
onClick={() => handleFollowupStatusToggle(f)}
className={`text-[10px] font-bold px-2 py-0.5 rounded uppercase cursor-pointer hover:opacity-80 transition-opacity ${f.status === 'DONE' ? 'bg-odoo-secondary/10 text-odoo-secondary' : 'bg-yellow-50 text-yellow-700'}`}
title={f.status === 'PENDING' ? "Click to mark as DONE" : "Completed"}
>
{f.status}
</button>
</div>
</div>
</div>
))
)}
</div>
</div>
</div> </div>
{/* Right Col: Info & Enquiries */} {/* Right Col: Info & Enquiries */}
@ -514,53 +508,90 @@ export default function ClientList() {
</div> </div>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6"> <div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<h3 className="font-bold text-gray-800 mb-4">Past Enquiries</h3> <h3 className="font-bold text-gray-800 mb-4">Products & Opportunities</h3>
<div className="space-y-4"> <div className="space-y-3">
{enquiries.length === 0 ? <p className="text-sm text-gray-500 italic">No enquiries on record.</p> : {getUnifiedProductsAndOpportunities().length === 0 ? (
enquiries.map(enq => ( <p className="text-sm text-gray-500 italic text-center py-2">No products or opportunities on record.</p>
<div key={enq.id} className="p-3 bg-gray-50 rounded-lg border border-gray-100 space-y-3"> ) : (
<div className="flex justify-between items-start"> getUnifiedProductsAndOpportunities().map(item => {
<p className="font-semibold text-sm text-gray-800">{enq.products.map(p => p.name).join(', ')}</p> const isSelected = selectedProductOrOpportunity?.id === item.id && selectedProductOrOpportunity?.type === item.type;
<span className="text-xs text-gray-400">{new Date(enq.createdAt).toLocaleDateString()}</span> return (
</div> <div
<p className="text-xs text-gray-500 line-clamp-2 italic">"{enq.conversation}"</p> key={`${item.type}-${item.id}`}
onClick={() => {
{/* Quote History inside Enquiry Card */} if (isSelected) {
{enq.quotes && enq.quotes.length > 0 && ( setSelectedProductOrOpportunity(null);
<div className="pt-3 border-t border-gray-200 mt-2 space-y-2"> } else {
<div className="text-[10px] uppercase font-black text-gray-400 tracking-wider">Related Quotations</div> setSelectedProductOrOpportunity({
{enq.quotes.map(quote => ( id: item.id,
<div key={quote.id} className="flex justify-between items-center bg-white p-2 rounded-lg border border-gray-100 shadow-sm"> type: item.type,
<div className="flex flex-col"> name: item.name,
<span className="text-[10px] font-bold text-gray-400">{new Date(quote.createdAt).toLocaleDateString()}</span> status: item.status
<span className="text-xs font-black text-gray-800">{quote.totalAmount.toLocaleString()}</span> });
</div> }
<div className="flex items-center space-x-2"> }}
{quote.pdfUrl && ( className={`p-3 rounded-lg border transition-all cursor-pointer select-none ${
<button isSelected
onClick={(e) => { ? 'bg-odoo-primary/5 border-odoo-primary shadow-sm'
e.stopPropagation(); : 'bg-gray-50 border-gray-100 hover:bg-white hover:border-gray-300'
window.open(quote.pdfUrl, '_blank'); }`}
}} >
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors text-odoo-primary" <div className="flex justify-between items-start mb-2">
title="Download PDF" <span className="font-bold text-sm text-gray-800 line-clamp-1">{item.name}</span>
> <span className={`text-[9px] font-extrabold px-1.5 py-0.5 rounded-full border ${
<Download size={14} /> item.type === 'opportunity'
</button> ? 'bg-emerald-50 text-emerald-600 border-emerald-200'
)} : 'bg-indigo-50 text-indigo-600 border-indigo-200'
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full border }`}>
${quote.status === 'SENT' ? 'bg-odoo-primary/10 text-odoo-primary border-odoo-primary/20' : {item.type === 'opportunity' ? 'OPPORTUNITY' : 'ENQUIRY'}
quote.status === 'ACCEPTED' ? 'bg-emerald-50 text-emerald-600 border-emerald-200' : </span>
'bg-gray-50 text-gray-500 border-gray-200'}`}>
{quote.status}
</span>
</div>
</div>
))}
</div> </div>
)}
</div> <div className="flex justify-between items-center text-xs text-gray-500 mb-2">
))} <span>Status: <strong className="text-gray-700">{item.status}</strong></span>
{item.value !== null && (
<span className="font-black text-gray-800">{item.value.toLocaleString()}</span>
)}
</div>
{/* Quote History inside Enquiry/Opportunity Card */}
{item.type === 'enquiry' && item.raw.quotes && item.raw.quotes.length > 0 && (
<div className="pt-2 mt-2 border-t border-gray-200/60 space-y-1.5">
<div className="text-[9px] uppercase font-black text-gray-400 tracking-wider">Related Quotations</div>
{item.raw.quotes.map((quote: any) => (
<div key={quote.id} className="flex justify-between items-center bg-white p-1.5 rounded border border-gray-100 shadow-sm" onClick={(e) => e.stopPropagation()}>
<div className="flex flex-col">
<span className="text-[9px] font-bold text-gray-400">{new Date(quote.createdAt).toLocaleDateString()}</span>
<span className="text-xs font-black text-gray-800">{quote.totalAmount.toLocaleString()}</span>
</div>
<div className="flex items-center space-x-1.5">
{quote.pdfUrl && (
<button
onClick={(e) => {
e.stopPropagation();
window.open(quote.pdfUrl, '_blank');
}}
className="p-1 hover:bg-gray-100 rounded transition-colors text-odoo-primary"
title="Download PDF"
>
<Download size={12} />
</button>
)}
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded-full border
${quote.status === 'SENT' ? 'bg-odoo-primary/10 text-odoo-primary border-odoo-primary/20' :
quote.status === 'ACCEPTED' ? 'bg-emerald-50 text-emerald-600 border-emerald-200' :
'bg-gray-50 text-gray-500 border-gray-200'}`}>
{quote.status}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
})
)}
</div> </div>
</div> </div>

View File

@ -33,7 +33,7 @@ interface ClientModalProps {
client: Client | null; client: Client | null;
} }
const STATUS_OPTIONS = ['LEAD', 'QUALITY', 'POTENTIAL', 'SALES', 'CLOSED']; // STATUS_OPTIONS removed as lifecycle status is no longer managed here
export default function ClientModal({ isOpen, onClose, onSave, onDelete, client }: ClientModalProps) { export default function ClientModal({ isOpen, onClose, onSave, onDelete, client }: ClientModalProps) {
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
@ -310,79 +310,12 @@ export default function ClientModal({ isOpen, onClose, onSave, onDelete, client
placeholder="e.g. Near Metro Station" placeholder="e.g. Near Metro Station"
/> />
</div> </div>
<div> {/* Lifecycle status removed as it is now managed at the product/opportunity level */}
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Lifecycle Status</label>
<div className="flex flex-wrap gap-2 mt-1">
{STATUS_OPTIONS.map(status => (
<button
key={status}
type="button"
onClick={() => setFormData({ ...formData, status })}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all border ${formData.status === status
? 'bg-odoo-primary text-white border-odoo-primary'
: 'bg-white text-gray-500 border-gray-200 hover:border-odoo-primary hover:text-odoo-primary'
}`}
>
{status}
</button>
))}
</div>
</div>
<div> {/* closingProbability, expectedClosingTimeframe, and assignedTo removed as they are managed at the opportunity level */}
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Closing Probability (%)</label>
<input
type="number"
min="0"
max="100"
value={formData.closingProbability === 0 ? '' : formData.closingProbability}
onChange={e => setFormData({ ...formData, closingProbability: e.target.value === '' ? 0 : parseInt(e.target.value) })}
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary outline-none transition-all"
placeholder="e.g. 75"
/>
</div>
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Expected Closing Timeframe</label>
<select
value={formData.expectedClosingTimeframe}
onChange={e => setFormData({ ...formData, expectedClosingTimeframe: e.target.value })}
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary outline-none transition-all"
>
<option value="">Select timeframe</option>
<option value="IMMEDIATE">Immediate (Within 1 week)</option>
<option value="SHORT_TERM">Short Term (1-4 weeks)</option>
<option value="MEDIUM_TERM">Medium Term (1-3 months)</option>
<option value="LONG_TERM">Long Term (3+ months)</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Assigned To</label>
<select
value={formData.assignedTo}
onChange={e => setFormData({ ...formData, assignedTo: e.target.value })}
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-odoo-primary outline-none transition-all"
>
<option value="">Select teammate</option>
<option value={currentUser?.id}>Myself ({currentUser?.name})</option>
{users.filter(u => u.id !== currentUser?.id).map(u => (
<option key={u.id} value={u.id}>{u.name} ({u.role})</option>
))}
</select>
</div>
</div> </div>
<div className="flex items-center gap-2 bg-blue-50/50 p-4 rounded-xl border border-blue-100 mt-2"> {/* Demo Completed checkbox removed from ClientModal */}
<input
type="checkbox"
id="isDemoDoneClient"
className="w-4 h-4 text-odoo-primary border-gray-200 rounded focus:ring-odoo-primary"
checked={formData.isDemoDone}
onChange={e => setFormData({ ...formData, isDemoDone: e.target.checked })}
/>
<label htmlFor="isDemoDoneClient" className="text-sm font-bold text-gray-700">Demo Activity Completed</label>
</div>
<div className="mt-6 p-4 bg-gray-50 rounded-xl border border-dashed border-gray-300"> <div className="mt-6 p-4 bg-gray-50 rounded-xl border border-dashed border-gray-300">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">

View File

@ -161,7 +161,7 @@ export default function DashboardOverview() {
]; ];
const pipelineChartData = { const pipelineChartData = {
labels: ['Lead', 'Qualified', 'Potential', 'Demo', 'Won'], labels: ['Lead', 'Qualified', 'Potential', 'SALES'],
datasets: [ datasets: [
{ {
label: 'Deals', label: 'Deals',
@ -456,7 +456,7 @@ export default function DashboardOverview() {
<div className="absolute left-[7px] top-8 bottom-[-24px] w-[2px] bg-slate-100" /> <div className="absolute left-[7px] top-8 bottom-[-24px] w-[2px] bg-slate-100" />
)} )}
<div className={`mt-1.5 w-4 h-4 rounded-full border-4 border-white shadow-sm shrink-0 z-10 ${ <div className={`mt-1.5 w-4 h-4 rounded-full border-4 border-white shadow-sm shrink-0 z-10 ${
op.stage === 'WON' ? 'bg-emerald-500' : 'bg-odoo-primary' op.stage === 'SALES' ? 'bg-emerald-500' : 'bg-odoo-primary'
}`} /> }`} />
<div> <div>
<p className="text-xs font-black text-slate-800 leading-tight"> <p className="text-xs font-black text-slate-800 leading-tight">

View File

@ -3,6 +3,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import api from '../lib/axios'; import api from '../lib/axios';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Eye, Check, X } from 'lucide-react';
interface Expense { interface Expense {
id: string; id: string;
@ -20,6 +21,8 @@ export default function ExpenseApproval() {
const [expenses, setExpenses] = useState<Expense[]>([]); const [expenses, setExpenses] = useState<Expense[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
useEffect(() => { useEffect(() => {
fetchExpenses(); fetchExpenses();
}, []); }, []);
@ -57,19 +60,35 @@ export default function ExpenseApproval() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</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-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bill</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{loading ? <tr><td colSpan={6} className="text-center py-4">Loading...</td></tr> : {loading ? <tr><td colSpan={7} className="text-center py-4">Loading...</td></tr> :
expenses.length === 0 ? <tr><td colSpan={6} className="text-center py-4">No expenses found</td></tr> : expenses.length === 0 ? <tr><td colSpan={7} className="text-center py-4">No expenses found</td></tr> :
expenses.map(expense => ( expenses.map(expense => (
<tr key={expense.id}> <tr key={expense.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{format(new Date(expense.createdAt), 'MMM dd, yyyy')}</td> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{format(new Date(expense.createdAt), 'MMM dd, yyyy')}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{expense.user.name}</td> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{expense.user.name}</td>
<td className="px-6 py-4 text-sm text-gray-500">{expense.description}</td> <td className="px-6 py-4 text-sm text-gray-500">{expense.description}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{expense.amount}</td> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{expense.amount}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{expense.imageUrl ? (
<a
href={`${API_URL}${expense.imageUrl}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-1 text-odoo-secondary hover:underline font-bold"
>
<Eye size={16} />
<span>View Bill</span>
</a>
) : (
<span className="text-gray-300 italic">No Bill</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full <span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full
${expense.status === 'APPROVED' ? 'bg-odoo-secondary/10 text-odoo-secondary' : ${expense.status === 'APPROVED' ? 'bg-odoo-secondary/10 text-odoo-secondary' :
@ -81,8 +100,20 @@ export default function ExpenseApproval() {
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
{expense.status === 'PENDING' && ( {expense.status === 'PENDING' && (
<> <>
<button onClick={() => handleUpdateStatus(expense.id, 'APPROVED')} className="text-odoo-secondary hover:text-odoo-secondary/80 font-semibold px-2">Approve</button> <button
<button onClick={() => handleUpdateStatus(expense.id, 'REJECTED')} className="text-rose-600 hover:text-rose-900 font-semibold px-2">Reject</button> onClick={() => handleUpdateStatus(expense.id, 'APPROVED')}
className="bg-odoo-secondary/10 text-odoo-secondary hover:bg-odoo-secondary hover:text-white p-2 rounded-lg transition-all"
title="Approve"
>
<Check size={18} />
</button>
<button
onClick={() => handleUpdateStatus(expense.id, 'REJECTED')}
className="bg-rose-50 text-rose-600 hover:bg-rose-600 hover:text-white p-2 rounded-lg transition-all"
title="Reject"
>
<X size={18} />
</button>
</> </>
)} )}
</td> </td>

View File

@ -0,0 +1,266 @@
'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 api from '../lib/axios';
import { useAuth } from '@/context/AuthContext';
import clsx from 'clsx';
export default function FloatingEventButton() {
const { user } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const [clients, setClients] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
type: 'CALL',
clientId: '',
userId: user?.id || '',
notes: '',
stage: 'LEAD',
date: new Date().toISOString().split('T')[0],
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }),
});
useEffect(() => {
if (isOpen) {
if (clients.length === 0) fetchClients();
if (users.length === 0) fetchUsers();
}
}, [isOpen]);
const fetchClients = async () => {
setLoading(true);
try {
const res = await api.get('/clients');
setClients(res.data);
} catch (e) {
console.error('Failed to fetch clients', e);
} finally {
setLoading(false);
}
};
const fetchUsers = async () => {
try {
const res = await api.get('/users');
setUsers(res.data.filter((u: any) => u.status === 'APPROVED'));
} catch (e) {
console.error('Failed to fetch users', e);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.clientId || !formData.notes) {
alert('Please select a client and add notes.');
return;
}
try {
const dateStr = `${formData.date}T${formData.time}:00`;
await api.post('/followups', {
...formData,
date: new Date(dateStr).toISOString(),
status: 'PENDING'
});
setIsOpen(false);
setFormData({
...formData,
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.');
}
};
const types = [
{ id: 'CALL', label: 'Call', icon: Phone, color: 'text-green-600', bg: 'bg-green-50', border: 'border-green-200' },
{ 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: '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: '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: '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' },
];
return (
<>
{/* Floating Button */}
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-8 right-8 w-14 h-14 bg-odoo-primary text-white rounded-full shadow-[0_8px_30px_rgb(0,160,157,0.4)] flex items-center justify-center hover:scale-110 hover:rotate-90 transition-all duration-300 z-[100] group"
title="Quick Event"
>
<Plus size={28} className="group-hover:scale-110" />
</button>
{/* Modal */}
{isOpen && (
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[101] flex items-center justify-center p-4 animate-in fade-in duration-200">
<div className="bg-white rounded-[32px] shadow-2xl w-full max-w-lg overflow-hidden flex flex-col animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="bg-odoo-primary p-6 text-white flex justify-between items-center">
<div>
<h3 className="text-xl font-black tracking-tight">Quick Event</h3>
<p className="text-white/70 text-xs font-bold uppercase tracking-widest mt-1">Schedule activity from anywhere</p>
</div>
<button
onClick={() => setIsOpen(false)}
className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-white/20 transition-colors"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6 overflow-y-auto max-h-[70vh] custom-scrollbar">
{/* Type Selector */}
<div className="grid grid-cols-5 gap-3">
{types.map((t) => {
const Icon = t.icon;
const isActive = formData.type === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => setFormData({ ...formData, type: t.id })}
className={clsx(
"flex flex-col items-center justify-center p-3 rounded-2xl border-2 transition-all group",
isActive
? `${t.border} ${t.bg} ${t.color} scale-105 shadow-sm`
: "border-slate-50 bg-slate-50 text-slate-400 hover:border-slate-200"
)}
>
<Icon size={18} className={clsx("transition-transform group-hover:scale-110", isActive && "mb-1")} />
<span className="text-[9px] font-black uppercase tracking-tight text-center leading-none mt-1">
{t.label}
</span>
</button>
);
})}
</div>
{/* Client & User */}
<div className="grid grid-cols-2 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
</label>
<select
required
value={formData.clientId}
onChange={(e) => setFormData({ ...formData, clientId: e.target.value })}
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>
))}
</select>
</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">
<User size={12} /> Assign To
</label>
<select
required
value={formData.userId}
onChange={(e) => setFormData({ ...formData, userId: e.target.value })}
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"
>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</div>
</div>
{/* Stage & Date */}
<div className="grid grid-cols-2 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">
<Clock size={12} /> Pipeline Stage
</label>
<select
required
value={formData.stage}
onChange={(e) => setFormData({ ...formData, stage: e.target.value })}
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="LEAD">LEAD</option>
<option value="QUALIFIED">QUALIFIED</option>
<option value="POTENTIAL">POTENTIAL</option>
<option value="SALES">SALES</option>
<option value="CLOSED">CLOSED</option>
</select>
</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">
<Calendar size={12} /> Date
</label>
<input
type="date"
required
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
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"
/>
</div>
</div>
{/* Time & Placeholder */}
<div className="grid grid-cols-2 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">
<Clock size={12} /> Time
</label>
<input
type="time"
required
value={formData.time}
onChange={(e) => setFormData({ ...formData, time: e.target.value })}
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"
/>
</div>
<div className="flex items-end pb-1">
<p className="text-[10px] text-slate-400 font-medium italic">
Event will be added to the activity pulse.
</p>
</div>
</div>
{/* Notes */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-[11px] font-black text-slate-400 uppercase tracking-widest ml-1">
<ListTodo size={12} /> Description / Notes
</label>
<textarea
required
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="What needs to be done?"
className="w-full bg-slate-50 border-none rounded-2xl px-4 py-4 text-sm font-semibold focus:ring-2 focus:ring-odoo-primary outline-none transition-all h-24 resize-none"
/>
</div>
{/* Submit */}
<button
type="submit"
className="w-full bg-odoo-primary text-white font-black py-4 rounded-2xl shadow-[0_8px_30px_rgb(0,160,157,0.2)] hover:shadow-[0_8px_30px_rgb(0,160,157,0.4)] hover:scale-[1.02] active:scale-[0.98] transition-all duration-300"
>
Schedule Now
</button>
</form>
</div>
</div>
)}
</>
);
}

View File

@ -31,7 +31,7 @@ interface Opportunity {
id: string; id: string;
title: string; title: string;
value: number; value: number;
stage: 'LEAD' | 'QUALIFIED' | 'POTENTIAL' | 'WON' | 'LOST'; stage: 'LEAD' | 'QUALIFIED' | 'POTENTIAL' | 'SALES' | 'LOST';
isDemoDone?: boolean; isDemoDone?: boolean;
client: { client: {
name: string; name: string;
@ -72,7 +72,7 @@ interface ColumnProps {
// --- Configuration --- // --- Configuration ---
const STAGE_CONFIG: Record<string, { title: string; bg: string; text: string; accent: string; bar: string }> = { const STAGE_CONFIG: Record<string, { title: string; bg: string; text: string; accent: string; bar: string }> = {
'LEAD': { 'LEAD': {
title: 'New Lead', title: 'Lead',
bg: 'bg-[#f8f9fa]', bg: 'bg-[#f8f9fa]',
text: 'text-gray-700', text: 'text-gray-700',
accent: 'bg-gray-400', accent: 'bg-gray-400',
@ -92,8 +92,8 @@ const STAGE_CONFIG: Record<string, { title: string; bg: string; text: string; ac
accent: 'bg-amber-500', accent: 'bg-amber-500',
bar: 'bg-amber-400' bar: 'bg-amber-400'
}, },
'WON': { 'SALES': {
title: 'Won', title: 'SALES',
bg: 'bg-[#e7f3f2]', bg: 'bg-[#e7f3f2]',
text: 'text-[#00A09D]', text: 'text-[#00A09D]',
accent: 'bg-[#00A09D]', accent: 'bg-[#00A09D]',
@ -328,7 +328,9 @@ export default function OpportunityBoard() {
creatorId: '', creatorId: '',
demoOwnerId: '', demoOwnerId: '',
closingOwnerId: '', closingOwnerId: '',
isDemoDone: false isDemoDone: false,
closingProbability: 0,
expectedClosingTimeframe: ''
}); });
const sensors = useSensors( const sensors = useSensors(
@ -405,7 +407,9 @@ export default function OpportunityBoard() {
creatorId: user?.id || '', creatorId: user?.id || '',
demoOwnerId: '', demoOwnerId: '',
closingOwnerId: '', closingOwnerId: '',
isDemoDone: false isDemoDone: false,
closingProbability: 0,
expectedClosingTimeframe: ''
}); });
setIsModalOpen(true); setIsModalOpen(true);
}; };
@ -449,7 +453,9 @@ export default function OpportunityBoard() {
creatorId: (item as any).creatorId || '', creatorId: (item as any).creatorId || '',
demoOwnerId: (item as any).demoOwnerId || '', demoOwnerId: (item as any).demoOwnerId || '',
closingOwnerId: (item as any).closingOwnerId || '', closingOwnerId: (item as any).closingOwnerId || '',
isDemoDone: !!item.isDemoDone isDemoDone: !!item.isDemoDone,
closingProbability: (item as any).closingProbability || 0,
expectedClosingTimeframe: (item as any).expectedClosingTimeframe || ''
}); });
setActiveModalTab('details'); setActiveModalTab('details');
setIsModalOpen(true); setIsModalOpen(true);
@ -515,7 +521,7 @@ export default function OpportunityBoard() {
newStage = overItem.stage; newStage = overItem.stage;
} }
const stages = ['LEAD', 'QUALIFIED', 'POTENTIAL', 'WON']; const stages = ['LEAD', 'QUALIFIED', 'POTENTIAL', 'SALES'];
if (activeItem.stage !== newStage && stages.includes(newStage as any)) { if (activeItem.stage !== newStage && stages.includes(newStage as any)) {
setItems((prev) => setItems((prev) =>
prev.map((item) => prev.map((item) =>
@ -526,7 +532,7 @@ export default function OpportunityBoard() {
try { try {
await api.patch(`/opportunities/${activeId}`, { stage: newStage }); await api.patch(`/opportunities/${activeId}`, { stage: newStage });
} catch (error: any) { } catch (error: any) {
if (newStage === 'WON') { if (newStage === 'SALES') {
// Open modal and explicitly set the target stage // Open modal and explicitly set the target stage
setEditingId(activeItem.id); setEditingId(activeItem.id);
setNewItemData({ setNewItemData({
@ -549,7 +555,9 @@ export default function OpportunityBoard() {
creatorId: (activeItem as any).creatorId || '', creatorId: (activeItem as any).creatorId || '',
demoOwnerId: (activeItem as any).demoOwnerId || '', demoOwnerId: (activeItem as any).demoOwnerId || '',
closingOwnerId: (activeItem as any).closingOwnerId || '', closingOwnerId: (activeItem as any).closingOwnerId || '',
isDemoDone: !!activeItem.isDemoDone isDemoDone: !!activeItem.isDemoDone,
closingProbability: (activeItem as any).closingProbability || 0,
expectedClosingTimeframe: (activeItem as any).expectedClosingTimeframe || ''
}); });
setIsModalOpen(true); setIsModalOpen(true);
} else { } else {
@ -561,7 +569,7 @@ export default function OpportunityBoard() {
} }
}; };
const stages = ['LEAD', 'QUALIFIED', 'POTENTIAL', 'WON']; const stages = ['LEAD', 'QUALIFIED', 'POTENTIAL', 'SALES'];
const getColumnTotal = (stage: string) => items.filter(i => i.stage === stage).reduce((sum, i) => sum + i.value, 0); const getColumnTotal = (stage: string) => items.filter(i => i.stage === stage).reduce((sum, i) => sum + i.value, 0);
return ( return (
@ -731,6 +739,37 @@ export default function OpportunityBoard() {
/> />
</div> </div>
<div>
<label className="block text-[13px] font-bold text-gray-500 mb-1">Closing Probability (%)</label>
<select
value={newItemData.closingProbability}
onChange={e => setNewItemData({ ...newItemData, closingProbability: parseInt(e.target.value) })}
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[14px]"
>
<option value={0}>Select probability</option>
<option value={20}>20%</option>
<option value={40}>40%</option>
<option value={60}>60%</option>
<option value={80}>80%</option>
<option value={100}>100%</option>
</select>
</div>
<div>
<label className="block text-[13px] font-bold text-gray-500 mb-1">Expected Closing Timeframe</label>
<select
value={newItemData.expectedClosingTimeframe}
onChange={e => setNewItemData({ ...newItemData, expectedClosingTimeframe: e.target.value })}
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[14px]"
>
<option value="">Select timeframe</option>
<option value="IMMEDIATE">Immediate (Within 1 week)</option>
<option value="SHORT_TERM">Short Term (1-4 weeks)</option>
<option value="MEDIUM_TERM">Medium Term (1-3 months)</option>
<option value="LONG_TERM">Long Term (3+ months)</option>
</select>
</div>
<div> <div>
<label className="block text-[13px] font-bold text-gray-500 mb-1">Priority</label> <label className="block text-[13px] font-bold text-gray-500 mb-1">Priority</label>
<div className="flex space-x-4 mt-2"> <div className="flex space-x-4 mt-2">
@ -835,16 +874,7 @@ export default function OpportunityBoard() {
</select> </select>
</div> </div>
<div className="flex items-center gap-2 pt-6"> {/* Demo Completed checkbox removed from OpportunityBoard */}
<input
type="checkbox"
id="isDemoDone"
className="w-4 h-4 text-odoo-primary border-gray-300 rounded focus:ring-odoo-primary"
checked={newItemData.isDemoDone}
onChange={e => setNewItemData({ ...newItemData, isDemoDone: e.target.checked })}
/>
<label htmlFor="isDemoDone" className="text-[14px] font-bold text-gray-700">Demo Completed?</label>
</div>
<div className="col-span-2 grid grid-cols-2 gap-4 border-t border-gray-100 pt-4"> <div className="col-span-2 grid grid-cols-2 gap-4 border-t border-gray-100 pt-4">
<div> <div>
@ -877,67 +907,10 @@ export default function OpportunityBoard() {
</div> </div>
</div> </div>
{/* DEMO ACTIVITY FIELDS */} {/* Demo activity details fields removed from OpportunityBoard */}
{newItemData.isDemoDone && (
<div className="col-span-2 grid grid-cols-2 gap-4 p-4 bg-blue-50/50 rounded border border-blue-100">
<div className="col-span-2 font-bold text-blue-700 text-[13px] mb-2 border-b border-blue-100 pb-1">
DEMO ACTIVITY DETAILS
</div>
<div>
<label className="block text-[12px] font-bold text-gray-500 mb-1">Demo Person Name</label>
<input
type="text"
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
value={newItemData.demoPersonName}
onChange={e => setNewItemData({ ...newItemData, demoPersonName: e.target.value })}
/>
</div>
<div>
<label className="block text-[12px] font-bold text-gray-500 mb-1">Demo Owner (Select Staff)</label>
<select
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
value={newItemData.demoOwnerId}
onChange={e => setNewItemData({ ...newItemData, demoOwnerId: e.target.value })}
>
<option value="">Select staff...</option>
{assignees.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</div>
<div>
<label className="block text-[12px] font-bold text-gray-500 mb-1">Contact Details</label>
<input
type="text"
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
value={newItemData.demoContactDetails}
onChange={e => setNewItemData({ ...newItemData, demoContactDetails: e.target.value })}
/>
</div>
<div>
<label className="block text-[12px] font-bold text-gray-500 mb-1">Competitor Mention</label>
<input
type="text"
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px]"
value={newItemData.competitorMention}
onChange={e => setNewItemData({ ...newItemData, competitorMention: e.target.value })}
/>
</div>
<div className="col-span-2">
<label className="block text-[12px] font-bold text-gray-500 mb-1">Key Queries & Objections</label>
<textarea
rows={2}
className="w-full border-b border-gray-200 py-1 bg-transparent focus:border-odoo-primary outline-none text-[13px] resize-none"
placeholder="Key queries, objections raised..."
value={newItemData.keyQueries}
onChange={e => setNewItemData({ ...newItemData, keyQueries: e.target.value, objections: e.target.value })}
/>
</div>
</div>
)}
{/* CLOSING STAGE FIELDS */} {/* CLOSING STAGE FIELDS */}
{newItemData.stage === 'WON' && ( {newItemData.stage === 'SALES' && (
<div className="col-span-2 grid grid-cols-2 gap-4 p-4 bg-green-50/50 rounded border border-green-100 mt-2"> <div className="col-span-2 grid grid-cols-2 gap-4 p-4 bg-green-50/50 rounded border border-green-100 mt-2">
<div className="col-span-2 font-bold text-green-700 text-[13px] mb-2 border-b border-green-100 pb-1"> <div className="col-span-2 font-bold text-green-700 text-[13px] mb-2 border-b border-green-100 pb-1">
CLOSING STAGE INFORMATION (MANDATORY) CLOSING STAGE INFORMATION (MANDATORY)

View File

@ -39,6 +39,7 @@ interface TargetData {
requiredClosures: number; requiredClosures: number;
avgDealValue: number; avgDealValue: number;
user?: { name: string }; user?: { name: string };
createdAt?: string;
} }
export default function TargetManager() { export default function TargetManager() {
@ -50,24 +51,25 @@ export default function TargetManager() {
// Form state // Form state
const [editingTarget, setEditingTarget] = useState<Partial<TargetData> | null>(null); const [editingTarget, setEditingTarget] = useState<Partial<TargetData> | null>(null);
const [selectedUserId, setSelectedUserId] = useState(''); const [selectedUserId, setSelectedUserId] = useState('');
const [monthlyTarget, setMonthlyTarget] = useState(400000); const [monthlyTarget, setMonthlyTarget] = useState<number | string>(400000);
const [minTarget, setMinTarget] = useState(200000); const [minTarget, setMinTarget] = useState<number | string>(200000);
const [avgDealValue, setAvgDealValue] = useState(40000); const [avgDealValue, setAvgDealValue] = useState<number | string>(40000);
const [month, setMonth] = useState(new Date().getMonth() + 1);
const [year, setYear] = useState(new Date().getFullYear());
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [historyUserId, setHistoryUserId] = useState<string | null>(null);
// Dynamic Engine State // Dynamic Engine State
const [requiredClosures, setRequiredClosures] = useState(0); const [requiredClosures, setRequiredClosures] = useState<number | string>(0);
const [requiredDemos, setRequiredDemos] = useState(0); const [requiredDemos, setRequiredDemos] = useState<number | string>(0);
const [requiredPotential, setRequiredPotential] = useState(0); const [requiredPotential, setRequiredPotential] = useState<number | string>(0);
const [requiredLeads, setRequiredLeads] = useState(0); const [requiredLeads, setRequiredLeads] = useState<number | string>(0);
const [dailyLeadTarget, setDailyLeadTarget] = useState(0); const [dailyLeadTarget, setDailyLeadTarget] = useState<number | string>(0);
// Auto-calculate defaults when core values change // Auto-calculate defaults when core values change
useEffect(() => { useEffect(() => {
if (!editingTarget) { if (!editingTarget) {
const calcClosures = Math.ceil(monthlyTarget / avgDealValue); const mTarget = Number(monthlyTarget) || 0;
const aDealValue = Number(avgDealValue) || 1; // prevent divide by zero
const calcClosures = Math.ceil(mTarget / aDealValue);
const calcDemos = calcClosures * 3; const calcDemos = calcClosures * 3;
const calcPotential = calcDemos * 2; const calcPotential = calcDemos * 2;
const calcLeads = calcPotential * 5 * 2; const calcLeads = calcPotential * 5 * 2;
@ -105,8 +107,8 @@ export default function TargetManager() {
try { try {
const payload = { const payload = {
userId: selectedUserId, userId: selectedUserId,
month: Number(month), month: new Date().getMonth() + 1,
year: Number(year), year: new Date().getFullYear(),
monthlyTarget: Number(monthlyTarget), monthlyTarget: Number(monthlyTarget),
minTarget: Number(minTarget), minTarget: Number(minTarget),
avgDealValue: Number(avgDealValue), avgDealValue: Number(avgDealValue),
@ -117,11 +119,8 @@ export default function TargetManager() {
dailyLeadTarget: Number(dailyLeadTarget) dailyLeadTarget: Number(dailyLeadTarget)
}; };
if (editingTarget?.id) { // Always create a new target to preserve history
await api.patch(`/targets/${editingTarget.id}`, payload); await api.post('/targets', payload);
} else {
await api.post('/targets', payload);
}
setIsModalOpen(false); setIsModalOpen(false);
setEditingTarget(null); setEditingTarget(null);
@ -148,8 +147,6 @@ export default function TargetManager() {
setMonthlyTarget(target.monthlyTarget); setMonthlyTarget(target.monthlyTarget);
setMinTarget(target.minTarget); setMinTarget(target.minTarget);
setAvgDealValue(target.avgDealValue || 40000); setAvgDealValue(target.avgDealValue || 40000);
setMonth(target.month);
setYear(target.year);
// Load existing benchmarks // Load existing benchmarks
setRequiredClosures(target.requiredClosures || Math.ceil(target.monthlyTarget / (target.avgDealValue || 40000))); setRequiredClosures(target.requiredClosures || Math.ceil(target.monthlyTarget / (target.avgDealValue || 40000)));
@ -161,10 +158,26 @@ export default function TargetManager() {
setIsModalOpen(true); setIsModalOpen(true);
}; };
const filteredTargets = targets.filter(t => const latestTargets = targets.reduce((acc: TargetData[], current) => {
const existingIndex = acc.findIndex(t => t.userId === current.userId);
if (existingIndex === -1) {
acc.push(current);
} else {
const existingTime = acc[existingIndex].createdAt ? new Date(acc[existingIndex].createdAt!).getTime() : 0;
const currentTime = current.createdAt ? new Date(current.createdAt).getTime() : 0;
if (currentTime > existingTime) {
acc[existingIndex] = current;
}
}
return acc;
}, []);
const filteredTargets = latestTargets.filter(t =>
t.user?.name.toLowerCase().includes(searchTerm.toLowerCase()) t.user?.name.toLowerCase().includes(searchTerm.toLowerCase())
); );
const historyTargets = historyUserId ? targets.filter(t => t.userId === historyUserId).sort((a, b) => new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime()) : [];
return ( return (
<div className="p-1 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700"> <div className="p-1 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* Header */} {/* Header */}
@ -225,16 +238,26 @@ export default function TargetManager() {
<h3 className="text-xl font-black text-slate-800">{target.user?.name}</h3> <h3 className="text-xl font-black text-slate-800">{target.user?.name}</h3>
<div className="flex items-center text-xs font-bold text-slate-400 uppercase tracking-widest mt-1"> <div className="flex items-center text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">
<TrendingUp size={12} className="mr-1 text-emerald-500" /> <TrendingUp size={12} className="mr-1 text-emerald-500" />
Target Period: {new Date(target.year, target.month-1).toLocaleString('default', { month: 'long', year: 'numeric' })} Active Target Configuration
</div> </div>
</div> </div>
</div> </div>
<button <div className="flex items-center space-x-2 relative z-10">
onClick={() => openEditModal(target)} <button
className="relative z-10 p-2.5 rounded-xl bg-slate-50 text-slate-400 hover:bg-odoo-primary/10 hover:text-odoo-primary transition-all" onClick={() => setHistoryUserId(target.userId)}
> className="p-2.5 rounded-xl bg-slate-50 text-slate-400 hover:bg-indigo-100 hover:text-indigo-600 transition-all"
<Edit2 size={18} /> title="View History"
</button> >
<BarChart3 size={18} />
</button>
<button
onClick={() => openEditModal(target)}
className="p-2.5 rounded-xl bg-slate-50 text-slate-400 hover:bg-odoo-primary/10 hover:text-odoo-primary transition-all"
title="Edit Target"
>
<Edit2 size={18} />
</button>
</div>
</div> </div>
{/* Revenue Stats */} {/* Revenue Stats */}
@ -327,7 +350,7 @@ export default function TargetManager() {
<input <input
type="number" type="number"
value={monthlyTarget} value={monthlyTarget}
onChange={e => setMonthlyTarget(Number(e.target.value))} onChange={e => setMonthlyTarget(e.target.value === '' ? '' : Number(e.target.value))}
className="w-full bg-slate-50 border-none rounded-2xl py-4 px-6 text-sm font-bold text-slate-700 outline-none focus:ring-2 focus:ring-odoo-primary/20 transition-all" className="w-full bg-slate-50 border-none rounded-2xl py-4 px-6 text-sm font-bold text-slate-700 outline-none focus:ring-2 focus:ring-odoo-primary/20 transition-all"
required required
/> />
@ -337,7 +360,7 @@ export default function TargetManager() {
<input <input
type="number" type="number"
value={minTarget} value={minTarget}
onChange={e => setMinTarget(Number(e.target.value))} onChange={e => setMinTarget(e.target.value === '' ? '' : Number(e.target.value))}
className="w-full bg-slate-50 border-none rounded-2xl py-4 px-6 text-sm font-bold text-slate-700 outline-none focus:ring-2 focus:ring-odoo-primary/20 transition-all" className="w-full bg-slate-50 border-none rounded-2xl py-4 px-6 text-sm font-bold text-slate-700 outline-none focus:ring-2 focus:ring-odoo-primary/20 transition-all"
required required
/> />
@ -347,21 +370,11 @@ export default function TargetManager() {
<input <input
type="number" type="number"
value={avgDealValue} value={avgDealValue}
onChange={e => setAvgDealValue(Number(e.target.value))} onChange={e => setAvgDealValue(e.target.value === '' ? '' : Number(e.target.value))}
className="w-full bg-slate-50 border-none rounded-2xl py-4 px-6 text-sm font-bold text-slate-700 outline-none focus:ring-2 focus:ring-odoo-primary/20 transition-all" className="w-full bg-slate-50 border-none rounded-2xl py-4 px-6 text-sm font-bold text-slate-700 outline-none focus:ring-2 focus:ring-odoo-primary/20 transition-all"
required required
/> />
</div> </div>
<div className="flex space-x-4">
<div className="flex-1">
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">Month</label>
<input type="number" min="1" max="12" value={month} onChange={e => setMonth(Number(e.target.value))} className="w-full bg-slate-50 border-none rounded-2xl py-4 px-6 text-sm font-bold text-slate-700 outline-none focus:ring-2 focus:ring-odoo-primary/20 transition-all" required />
</div>
<div className="flex-1">
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">Year</label>
<input type="number" value={year} onChange={e => setYear(Number(e.target.value))} className="w-full bg-slate-50 border-none rounded-2xl py-4 px-6 text-sm font-bold text-slate-700 outline-none focus:ring-2 focus:ring-odoo-primary/20 transition-all" required />
</div>
</div>
</div> </div>
<div className="pt-6 flex space-x-4"> <div className="pt-6 flex space-x-4">
@ -397,7 +410,7 @@ export default function TargetManager() {
<input <input
type="number" type="number"
value={dailyLeadTarget} value={dailyLeadTarget}
onChange={e => setDailyLeadTarget(Number(e.target.value))} onChange={e => setDailyLeadTarget(e.target.value === '' ? '' : Number(e.target.value))}
className="w-full bg-transparent text-3xl font-black leading-none outline-none text-white border-b border-white/20 pb-1 focus:border-white transition-all" className="w-full bg-transparent text-3xl font-black leading-none outline-none text-white border-b border-white/20 pb-1 focus:border-white transition-all"
/> />
<p className="text-[9px] font-bold text-white/50 uppercase tracking-widest mt-1">Leads / Day</p> <p className="text-[9px] font-bold text-white/50 uppercase tracking-widest mt-1">Leads / Day</p>
@ -410,7 +423,7 @@ export default function TargetManager() {
<input <input
type="number" type="number"
value={requiredDemos} value={requiredDemos}
onChange={e => setRequiredDemos(Number(e.target.value))} onChange={e => setRequiredDemos(e.target.value === '' ? '' : Number(e.target.value))}
className="w-20 bg-white/10 text-white text-sm font-black text-right px-2 py-1 rounded outline-none focus:ring-1 focus:ring-white/50" className="w-20 bg-white/10 text-white text-sm font-black text-right px-2 py-1 rounded outline-none focus:ring-1 focus:ring-white/50"
/> />
</div> </div>
@ -419,7 +432,7 @@ export default function TargetManager() {
<input <input
type="number" type="number"
value={requiredPotential} value={requiredPotential}
onChange={e => setRequiredPotential(Number(e.target.value))} onChange={e => setRequiredPotential(e.target.value === '' ? '' : Number(e.target.value))}
className="w-20 bg-white/10 text-white text-sm font-black text-right px-2 py-1 rounded outline-none focus:ring-1 focus:ring-white/50" className="w-20 bg-white/10 text-white text-sm font-black text-right px-2 py-1 rounded outline-none focus:ring-1 focus:ring-white/50"
/> />
</div> </div>
@ -428,7 +441,7 @@ export default function TargetManager() {
<input <input
type="number" type="number"
value={requiredLeads} value={requiredLeads}
onChange={e => setRequiredLeads(Number(e.target.value))} onChange={e => setRequiredLeads(e.target.value === '' ? '' : Number(e.target.value))}
className="w-20 bg-white/10 text-white text-sm font-black text-right px-2 py-1 rounded outline-none focus:ring-1 focus:ring-white/50" className="w-20 bg-white/10 text-white text-sm font-black text-right px-2 py-1 rounded outline-none focus:ring-1 focus:ring-white/50"
/> />
</div> </div>
@ -437,7 +450,7 @@ export default function TargetManager() {
<input <input
type="number" type="number"
value={requiredClosures} value={requiredClosures}
onChange={e => setRequiredClosures(Number(e.target.value))} onChange={e => setRequiredClosures(e.target.value === '' ? '' : Number(e.target.value))}
className="w-20 bg-emerald-500/20 text-emerald-400 text-sm font-black text-right px-2 py-1 rounded outline-none focus:ring-1 focus:ring-emerald-500/50" className="w-20 bg-emerald-500/20 text-emerald-400 text-sm font-black text-right px-2 py-1 rounded outline-none focus:ring-1 focus:ring-emerald-500/50"
/> />
</div> </div>
@ -464,6 +477,65 @@ export default function TargetManager() {
</div> </div>
</div> </div>
)} )}
{/* History Modal */}
{historyUserId && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setHistoryUserId(null)}></div>
<div className="bg-white rounded-[32px] w-full max-w-3xl max-h-[80vh] overflow-hidden flex flex-col shadow-2xl relative z-10 animate-in zoom-in-95 duration-300">
<div className="p-8 border-b border-gray-100 flex justify-between items-center bg-slate-50">
<div>
<h3 className="text-2xl font-black text-slate-800">Target History</h3>
<p className="text-slate-500 font-medium text-sm mt-1">
Historical configurations for {historyTargets[0]?.user?.name || 'User'}
</p>
</div>
<button onClick={() => setHistoryUserId(null)} className="text-slate-400 hover:text-slate-600 bg-white p-2 rounded-xl shadow-sm">
</button>
</div>
<div className="overflow-y-auto p-8 bg-white flex-1 space-y-6">
{historyTargets.length === 0 ? (
<p className="text-center text-slate-500">No history available.</p>
) : (
historyTargets.map((t, idx) => (
<div key={t.id} className="relative pl-8 pb-8 border-l-2 border-indigo-100 last:border-transparent last:pb-0">
<div className="absolute left-[-9px] top-0 w-4 h-4 rounded-full bg-odoo-primary ring-4 ring-white" />
<div className="bg-slate-50 rounded-2xl p-6 border border-slate-100 shadow-sm">
<div className="flex justify-between items-center mb-4">
<span className="text-xs font-black text-indigo-600 uppercase tracking-widest bg-indigo-100 px-3 py-1 rounded-lg">
{idx === 0 ? 'Current Active Target' : 'Historical Target'}
</span>
<span className="text-xs font-bold text-slate-400">
{t.createdAt ? new Date(t.createdAt).toLocaleString() : 'Unknown Date'}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-[10px] font-black text-slate-400 uppercase">Monthly Target</p>
<p className="font-bold text-slate-700">{t.monthlyTarget.toLocaleString()}</p>
</div>
<div>
<p className="text-[10px] font-black text-slate-400 uppercase">Daily Leads</p>
<p className="font-bold text-slate-700">{t.dailyLeadTarget}</p>
</div>
<div>
<p className="text-[10px] font-black text-slate-400 uppercase">Required Demos</p>
<p className="font-bold text-slate-700">{t.requiredDemos}</p>
</div>
<div>
<p className="text-[10px] font-black text-slate-400 uppercase">Required Closures</p>
<p className="font-bold text-slate-700">{t.requiredClosures}</p>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -3,7 +3,8 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import api from '../lib/axios'; import api from '../lib/axios';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { Check, X } from 'lucide-react'; import { Check, X, Shield, Lock } from 'lucide-react';
import clsx from 'clsx';
interface User { interface User {
id: string; id: string;
@ -13,8 +14,28 @@ interface User {
status: string; status: string;
managerId?: string; managerId?: string;
manager?: { name: string }; manager?: { name: string };
permissions?: string;
} }
const ALL_PERMISSIONS = [
{ id: 'dashboard', label: 'Dashboard', description: 'Access to main overview' },
{ id: 'tracking', label: 'Live Tracking', description: 'Real-time location of field staff' },
{ id: 'opportunities', label: 'Opportunities', description: 'Pipeline and sales board' },
{ id: 'clients', label: 'Clients', icon: 'Users', description: 'Client management' },
{ id: 'quotes', label: 'Quotes', description: 'Quote generation and tracking' },
{ id: 'expenses', label: 'Expenses', description: 'Approval of expense claims' },
{ id: 'incentives', label: 'Incentives', description: 'Incentive tracking' },
{ id: 'reports', label: 'Reports', description: 'Data analytics and exports' },
{ id: 'targets', label: 'Targets', description: 'Assigning sales goals' },
{ id: 'activities', label: 'Activities', description: 'Task and event logs' },
{ id: 'call-logs', label: 'Call Logs', description: 'Mobile call history' },
{ id: 'funnel-analysis', label: 'Funnel Analysis', description: 'Sales conversion metrics' },
{ id: 'pipeline-engine', label: 'Pipeline Engine', description: 'Calculations and targets engine' },
{ id: 'products', label: 'Products', description: 'Inventory/Product catalog' },
{ id: 'users', label: 'Users', description: 'Managing user access' },
{ id: 'settings', label: 'Settings', description: 'System configurations' },
];
export default function UserManager() { export default function UserManager() {
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
@ -25,6 +46,10 @@ export default function UserManager() {
const [role, setRole] = useState('TELESALES_EXECUTIVE'); const [role, setRole] = useState('TELESALES_EXECUTIVE');
const [managerId, setManagerId] = useState(''); const [managerId, setManagerId] = useState('');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [showPermissionModal, setShowPermissionModal] = useState(false);
const [userPermissions, setUserPermissions] = useState<string[]>([]);
const [savingPermissions, setSavingPermissions] = useState(false);
const isAdminOrGM = currentUser?.role === 'ADMIN' || currentUser?.role === 'GENERAL_MANAGER'; const isAdminOrGM = currentUser?.role === 'ADMIN' || currentUser?.role === 'GENERAL_MANAGER';
@ -79,6 +104,36 @@ export default function UserManager() {
} }
}; };
const handleManagePermissions = (user: User) => {
setSelectedUser(user);
setUserPermissions(user.permissions ? JSON.parse(user.permissions) : []);
setShowPermissionModal(true);
};
const togglePermission = (permId: string) => {
setUserPermissions(prev =>
prev.includes(permId) ? prev.filter(p => p !== permId) : [...prev, permId]
);
};
const savePermissions = async () => {
if (!selectedUser) return;
setSavingPermissions(true);
try {
await api.patch(`/users/${selectedUser.id}`, {
permissions: JSON.stringify(userPermissions)
});
setShowPermissionModal(false);
fetchUsers();
alert('Permissions updated successfully');
} catch (error) {
console.error(error);
alert('Failed to update permissions');
} finally {
setSavingPermissions(false);
}
};
const pendingUsers = users.filter(u => u.status === 'PENDING'); const pendingUsers = users.filter(u => u.status === 'PENDING');
const approvedUsers = users.filter(u => u.status === 'APPROVED'); const approvedUsers = users.filter(u => u.status === 'APPROVED');
@ -177,6 +232,7 @@ export default function UserManager() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Reporting To</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Reporting To</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
{isAdminOrGM && <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>}
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
@ -205,11 +261,88 @@ export default function UserManager() {
Active Active
</span> </span>
</td> </td>
{isAdminOrGM && (
<td className="px-6 py-4 whitespace-nowrap text-right">
<button
onClick={() => handleManagePermissions(user)}
className="inline-flex items-center space-x-1.5 px-3 py-1.5 bg-slate-100 hover:bg-odoo-primary hover:text-white text-slate-600 rounded-lg text-xs font-bold transition-all"
>
<Shield size={14} />
<span>Manage Access</span>
</button>
</td>
)}
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Permissions Modal */}
{showPermissionModal && selectedUser && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-[24px] shadow-2xl w-full max-w-2xl overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="bg-odoo-primary p-6 text-white flex justify-between items-center">
<div>
<h4 className="text-xl font-bold">Permissions Control</h4>
<p className="text-white/70 text-sm">{selectedUser.name} &bull; {selectedUser.email}</p>
</div>
<button onClick={() => setShowPermissionModal(false)} className="hover:bg-white/10 p-2 rounded-full transition-colors">
<X size={24} />
</button>
</div>
<div className="p-6 max-h-[60vh] overflow-y-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{ALL_PERMISSIONS.map(perm => (
<div
key={perm.id}
onClick={() => togglePermission(perm.id)}
className={clsx(
"flex items-start space-x-3 p-4 rounded-2xl border-2 cursor-pointer transition-all",
userPermissions.includes(perm.id)
? "border-odoo-primary bg-odoo-primary/5 shadow-sm"
: "border-slate-100 hover:border-slate-200"
)}
>
<div className={clsx(
"mt-0.5 w-5 h-5 rounded flex items-center justify-center border-2 transition-all",
userPermissions.includes(perm.id)
? "bg-odoo-primary border-odoo-primary"
: "border-slate-300 bg-white"
)}>
{userPermissions.includes(perm.id) && <Check size={14} className="text-white" />}
</div>
<div>
<span className={clsx(
"text-sm font-bold block mb-0.5",
userPermissions.includes(perm.id) ? "text-odoo-primary" : "text-slate-700"
)}>{perm.label}</span>
<span className="text-[11px] text-slate-500 leading-tight block">{perm.description}</span>
</div>
</div>
))}
</div>
</div>
<div className="p-6 bg-slate-50 border-t border-slate-100 flex justify-end space-x-3">
<button
onClick={() => setShowPermissionModal(false)}
className="px-6 py-2 text-sm font-bold text-slate-600 hover:bg-slate-200 rounded-xl transition-all"
>
Cancel
</button>
<button
onClick={savePermissions}
disabled={savingPermissions}
className="px-8 py-2 bg-odoo-primary text-white text-sm font-bold rounded-xl shadow-lg shadow-odoo-primary/20 hover:scale-[1.02] active:scale-[0.98] transition-all disabled:opacity-50"
>
{savingPermissions ? 'Saving...' : 'Apply Permissions'}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -10,6 +10,7 @@ interface User {
email: string; email: string;
name: string; name: string;
role: string; role: string;
permissions?: string;
} }
interface AuthContextType { interface AuthContextType {

View File

@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@ -19,7 +23,9 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
"./src/*"
]
} }
}, },
"include": [ "include": [
@ -27,8 +33,10 @@
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts", "**/*.mts",
"**/*.mts" ".next/dev/types/**/*.ts"
], ],
"exclude": ["node_modules"] "exclude": [
"node_modules"
]
} }