parent
3bb4c35def
commit
9a875aea9f
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,10 +164,26 @@ export default function ActivitiesManager({ initialClientId, initialOpportunityI
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!feedbackActivity) return;
|
if (!feedbackActivity) return;
|
||||||
|
|
||||||
|
const isMandatoryType = ['DEMO_COMPLETED', 'VISIT_COMPLETED', 'DEMO'].includes(feedbackActivity.type);
|
||||||
|
|
||||||
|
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) {
|
if (!demoFeedback.demoPersonName || !demoFeedback.demoContactDetails) {
|
||||||
alert('Please provide Person Met and Contact Details.');
|
alert('Please provide Person Met and Contact Details.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.patch(`/followups/${feedbackActivity.id}`, {
|
await api.patch(`/followups/${feedbackActivity.id}`, {
|
||||||
|
|
@ -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,49 +692,125 @@ 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>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
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"
|
||||||
|
rows={2} placeholder="Customer's reaction/feedback"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Requirement Details *</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
value={demoFeedback.requirementDetails}
|
||||||
|
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 resize-none"
|
||||||
|
rows={2} placeholder="What are their specific needs?"
|
||||||
|
/>
|
||||||
|
</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
|
<input
|
||||||
type="text" required
|
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}
|
value={demoFeedback.demoPersonName}
|
||||||
onChange={e => setDemoFeedback({ ...demoFeedback, demoPersonName: e.target.value })}
|
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"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Contact Details *</label>
|
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Contact Details</label>
|
||||||
<input
|
<input
|
||||||
type="text" required
|
type="text"
|
||||||
value={demoFeedback.demoContactDetails}
|
value={demoFeedback.demoContactDetails}
|
||||||
onChange={e => setDemoFeedback({ ...demoFeedback, demoContactDetails: e.target.value })}
|
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"
|
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>
|
||||||
<div>
|
|
||||||
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Key Queries</label>
|
|
||||||
<textarea
|
|
||||||
value={demoFeedback.keyQueries}
|
|
||||||
onChange={e => setDemoFeedback({ ...demoFeedback, keyQueries: e.target.value })}
|
|
||||||
className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm resize-none"
|
|
||||||
rows={3} placeholder="What did they ask about?"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-[11px] font-bold text-gray-400 mb-1 uppercase tracking-widest">Competitor Mentioned</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={demoFeedback.competitorMention}
|
|
||||||
onChange={e => setDemoFeedback({ ...demoFeedback, competitorMention: e.target.value })}
|
|
||||||
className="w-full p-2.5 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-odoo-primary text-sm"
|
|
||||||
placeholder="e.g. Salesforce, Zoho"
|
|
||||||
/>
|
|
||||||
</div>
|
</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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
{selectedProductOrOpportunity
|
||||||
|
? `Status: ${selectedProductOrOpportunity.name}`
|
||||||
|
: 'Current Status'}
|
||||||
|
</label>
|
||||||
|
{selectedProductOrOpportunity ? (
|
||||||
<div className="flex bg-white rounded-lg shadow-sm border border-gray-200 p-1">
|
<div className="flex bg-white rounded-lg shadow-sm border border-gray-200 p-1">
|
||||||
{STATUS_OPTIONS.map((status) => (
|
{selectedProductOrOpportunity.type === 'opportunity' ? (
|
||||||
|
OPP_STATUS_OPTIONS.map((status) => (
|
||||||
<button
|
<button
|
||||||
key={status}
|
key={status}
|
||||||
onClick={(e) => { e.stopPropagation(); handleStatusUpdate(status); }}
|
onClick={(e) => {
|
||||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${selectedClient.status === status
|
e.stopPropagation();
|
||||||
? 'bg-odoo-primary text-white shadow'
|
handleOpportunityStageUpdate(selectedProductOrOpportunity.id, status);
|
||||||
|
}}
|
||||||
|
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'
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
</button>
|
</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>
|
||||||
|
) : (
|
||||||
|
<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,41 +508,76 @@ 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
|
||||||
|
key={`${item.type}-${item.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (isSelected) {
|
||||||
|
setSelectedProductOrOpportunity(null);
|
||||||
|
} else {
|
||||||
|
setSelectedProductOrOpportunity({
|
||||||
|
id: item.id,
|
||||||
|
type: item.type,
|
||||||
|
name: item.name,
|
||||||
|
status: item.status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`p-3 rounded-lg border transition-all cursor-pointer select-none ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-odoo-primary/5 border-odoo-primary shadow-sm'
|
||||||
|
: 'bg-gray-50 border-gray-100 hover:bg-white hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<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 ${
|
||||||
|
item.type === 'opportunity'
|
||||||
|
? 'bg-emerald-50 text-emerald-600 border-emerald-200'
|
||||||
|
: 'bg-indigo-50 text-indigo-600 border-indigo-200'
|
||||||
|
}`}>
|
||||||
|
{item.type === 'opportunity' ? 'OPPORTUNITY' : 'ENQUIRY'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 line-clamp-2 italic">"{enq.conversation}"</p>
|
|
||||||
|
|
||||||
{/* Quote History inside Enquiry Card */}
|
<div className="flex justify-between items-center text-xs text-gray-500 mb-2">
|
||||||
{enq.quotes && enq.quotes.length > 0 && (
|
<span>Status: <strong className="text-gray-700">{item.status}</strong></span>
|
||||||
<div className="pt-3 border-t border-gray-200 mt-2 space-y-2">
|
{item.value !== null && (
|
||||||
<div className="text-[10px] uppercase font-black text-gray-400 tracking-wider">Related Quotations</div>
|
<span className="font-black text-gray-800">₹{item.value.toLocaleString()}</span>
|
||||||
{enq.quotes.map(quote => (
|
)}
|
||||||
<div key={quote.id} className="flex justify-between items-center bg-white p-2 rounded-lg border border-gray-100 shadow-sm">
|
</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">
|
<div className="flex flex-col">
|
||||||
<span className="text-[10px] font-bold text-gray-400">{new Date(quote.createdAt).toLocaleDateString()}</span>
|
<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>
|
<span className="text-xs font-black text-gray-800">₹{quote.totalAmount.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-1.5">
|
||||||
{quote.pdfUrl && (
|
{quote.pdfUrl && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
window.open(quote.pdfUrl, '_blank');
|
window.open(quote.pdfUrl, '_blank');
|
||||||
}}
|
}}
|
||||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors text-odoo-primary"
|
className="p-1 hover:bg-gray-100 rounded transition-colors text-odoo-primary"
|
||||||
title="Download PDF"
|
title="Download PDF"
|
||||||
>
|
>
|
||||||
<Download size={14} />
|
<Download size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full border
|
<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 === 'SENT' ? 'bg-odoo-primary/10 text-odoo-primary border-odoo-primary/20' :
|
||||||
quote.status === 'ACCEPTED' ? 'bg-emerald-50 text-emerald-600 border-emerald-200' :
|
quote.status === 'ACCEPTED' ? 'bg-emerald-50 text-emerald-600 border-emerald-200' :
|
||||||
'bg-gray-50 text-gray-500 border-gray-200'}`}>
|
'bg-gray-50 text-gray-500 border-gray-200'}`}>
|
||||||
|
|
@ -560,7 +589,9 @@ export default function ClientList() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
{/* closingProbability, expectedClosingTimeframe, and assignedTo removed as they are managed at the opportunity level */}
|
||||||
{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>
|
||||||
|
|
||||||
<div>
|
{/* Demo Completed checkbox removed from ClientModal */}
|
||||||
<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 className="flex items-center gap-2 bg-blue-50/50 p-4 rounded-xl border border-blue-100 mt-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="isDemoDoneClient"
|
|
||||||
className="w-4 h-4 text-odoo-primary border-gray-200 rounded focus:ring-odoo-primary"
|
|
||||||
checked={formData.isDemoDone}
|
|
||||||
onChange={e => setFormData({ ...formData, isDemoDone: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<label htmlFor="isDemoDoneClient" className="text-sm font-bold text-gray-700">Demo Activity Completed</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 p-4 bg-gray-50 rounded-xl border border-dashed border-gray-300">
|
<div className="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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
} else {
|
|
||||||
await api.post('/targets', payload);
|
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,17 +238,27 @@ 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>
|
||||||
|
<div className="flex items-center space-x-2 relative z-10">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
title="View History"
|
||||||
|
>
|
||||||
|
<BarChart3 size={18} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditModal(target)}
|
onClick={() => openEditModal(target)}
|
||||||
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"
|
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} />
|
<Edit2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Revenue Stats */}
|
{/* Revenue Stats */}
|
||||||
<div className="grid grid-cols-2 gap-4 mb-8">
|
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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} • {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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface User {
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
permissions?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue