Skip to content

Commit dab22c7

Browse files
committed
Phases 166-170: CRM Module — React pages + tests (21 passing)
Dashboard, Leads CRUD (Index/Create/Show/Edit), Pipeline Stages, Reports (Pipeline/WinLoss/Source), Sidebar nav wiring, and 21 feature tests covering all CRUD + markWon/markLost/convert/activities. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 0b89e86 commit dab22c7

11 files changed

Lines changed: 1315 additions & 0 deletions

File tree

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,24 @@ const navItems: NavItem[] = [
240240
{ label: 'Reports: BOM Cost', href: '/manufacturing/reports/bom-cost', icon: <span /> },
241241
],
242242
},
243+
{
244+
label: 'CRM',
245+
href: '/crm/dashboard',
246+
icon: (
247+
<svg className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
248+
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
249+
</svg>
250+
),
251+
permission: 'finance.view',
252+
children: [
253+
{ label: 'Dashboard', href: '/crm/dashboard', icon: <span /> },
254+
{ label: 'Leads', href: '/crm/leads', icon: <span /> },
255+
{ label: 'Pipeline Stages', href: '/crm/stages', icon: <span /> },
256+
{ label: 'Reports: Pipeline', href: '/crm/reports/pipeline', icon: <span /> },
257+
{ label: 'Reports: Win/Loss', href: '/crm/reports/win-loss', icon: <span /> },
258+
{ label: 'Reports: Source', href: '/crm/reports/source', icon: <span /> },
259+
],
260+
},
243261
{
244262
label: 'Analytics',
245263
href: '/analytics',
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { Head, Link } from '@inertiajs/react';
2+
import AppLayout from '@/Layouts/AppLayout';
3+
import type { PageProps } from '@/types';
4+
5+
interface Stats {
6+
totalLeads: number;
7+
totalOpportunities: number;
8+
totalExpectedRevenue: number;
9+
wonThisMonth: number;
10+
wonRevenueThisMonth: number;
11+
conversionRate: number;
12+
}
13+
14+
interface PipelineStage {
15+
id: number;
16+
name: string;
17+
color: string | null;
18+
lead_count: number;
19+
expected_revenue_sum: number | null;
20+
}
21+
22+
interface RecentLead {
23+
id: number;
24+
reference: string | null;
25+
title: string;
26+
status: string;
27+
priority: string;
28+
expected_close_date: string | null;
29+
stage: { id: number; name: string } | null;
30+
assignee: { id: number; name: string } | null;
31+
}
32+
33+
interface Props extends PageProps {
34+
stats: Stats;
35+
pipelineByStage: PipelineStage[];
36+
recentLeads: RecentLead[];
37+
}
38+
39+
const priorityBadge: Record<string, string> = {
40+
low: 'bg-slate-100 text-slate-700',
41+
normal: 'bg-blue-100 text-blue-700',
42+
high: 'bg-orange-100 text-orange-700',
43+
urgent: 'bg-red-100 text-red-700',
44+
};
45+
46+
const statusBadge: Record<string, string> = {
47+
open: 'bg-blue-100 text-blue-700',
48+
won: 'bg-green-100 text-green-700',
49+
lost: 'bg-red-100 text-red-700',
50+
};
51+
52+
function fmt(n: number): string {
53+
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n);
54+
}
55+
56+
export default function CrmDashboard({ stats, pipelineByStage, recentLeads }: Props) {
57+
const kpis = [
58+
{ label: 'Open Leads', value: stats.totalLeads, color: 'text-blue-600' },
59+
{ label: 'Open Opportunities', value: stats.totalOpportunities, color: 'text-indigo-600' },
60+
{ label: 'Expected Revenue', value: fmt(stats.totalExpectedRevenue), color: 'text-emerald-600' },
61+
{ label: 'Won This Month', value: stats.wonThisMonth, color: 'text-green-600' },
62+
{ label: 'Won Revenue/Month', value: fmt(stats.wonRevenueThisMonth), color: 'text-teal-600' },
63+
{ label: 'Conversion Rate', value: `${stats.conversionRate}%`, color: 'text-purple-600' },
64+
];
65+
66+
return (
67+
<AppLayout>
68+
<Head title="CRM Dashboard" />
69+
<div className="space-y-6">
70+
<h1 className="text-2xl font-semibold text-slate-900">CRM Dashboard</h1>
71+
72+
{/* KPI Cards */}
73+
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
74+
{kpis.map((kpi) => (
75+
<div key={kpi.label} className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
76+
<p className="text-xs font-medium uppercase text-slate-500">{kpi.label}</p>
77+
<p className={`mt-2 text-xl font-bold ${kpi.color}`}>{kpi.value}</p>
78+
</div>
79+
))}
80+
</div>
81+
82+
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
83+
{/* Pipeline by Stage */}
84+
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
85+
<div className="border-b border-slate-200 px-6 py-4">
86+
<h2 className="text-base font-semibold text-slate-800">Pipeline by Stage</h2>
87+
</div>
88+
<div className="overflow-x-auto">
89+
<table className="min-w-full divide-y divide-slate-200">
90+
<thead className="bg-slate-50">
91+
<tr>
92+
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-slate-500">Stage</th>
93+
<th className="px-4 py-3 text-right text-xs font-medium uppercase text-slate-500">Leads</th>
94+
<th className="px-4 py-3 text-right text-xs font-medium uppercase text-slate-500">Expected Rev.</th>
95+
</tr>
96+
</thead>
97+
<tbody className="divide-y divide-slate-100">
98+
{pipelineByStage.map((stage) => (
99+
<tr key={stage.id} className="hover:bg-slate-50">
100+
<td className="px-4 py-3 text-sm text-slate-800 flex items-center gap-2">
101+
{stage.color && (
102+
<span
103+
className="inline-block h-3 w-3 rounded-full flex-shrink-0"
104+
style={{ backgroundColor: stage.color }}
105+
/>
106+
)}
107+
{stage.name}
108+
</td>
109+
<td className="px-4 py-3 text-right text-sm text-slate-700">{stage.lead_count}</td>
110+
<td className="px-4 py-3 text-right text-sm text-slate-700">
111+
{fmt(stage.expected_revenue_sum ?? 0)}
112+
</td>
113+
</tr>
114+
))}
115+
{pipelineByStage.length === 0 && (
116+
<tr>
117+
<td colSpan={3} className="px-4 py-8 text-center text-sm text-slate-400">No stages configured</td>
118+
</tr>
119+
)}
120+
</tbody>
121+
</table>
122+
</div>
123+
</div>
124+
125+
{/* Recent Leads */}
126+
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
127+
<div className="border-b border-slate-200 px-6 py-4 flex items-center justify-between">
128+
<h2 className="text-base font-semibold text-slate-800">Recent Leads</h2>
129+
<Link href="/crm/leads" className="text-sm text-indigo-600 hover:text-indigo-800">View all</Link>
130+
</div>
131+
<div className="overflow-x-auto">
132+
<table className="min-w-full divide-y divide-slate-200">
133+
<thead className="bg-slate-50">
134+
<tr>
135+
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-slate-500">Reference</th>
136+
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-slate-500">Title</th>
137+
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-slate-500">Stage</th>
138+
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-slate-500">Priority</th>
139+
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-slate-500">Close Date</th>
140+
</tr>
141+
</thead>
142+
<tbody className="divide-y divide-slate-100">
143+
{recentLeads.map((lead) => (
144+
<tr key={lead.id} className="hover:bg-slate-50">
145+
<td className="px-4 py-3 text-sm">
146+
<Link href={`/crm/leads/${lead.id}`} className="text-indigo-600 hover:text-indigo-800 font-mono text-xs">
147+
{lead.reference ?? `#${lead.id}`}
148+
</Link>
149+
</td>
150+
<td className="px-4 py-3 text-sm text-slate-800 max-w-[140px] truncate">{lead.title}</td>
151+
<td className="px-4 py-3 text-sm text-slate-600">{lead.stage?.name ?? '—'}</td>
152+
<td className="px-4 py-3">
153+
<span className={`inline-block rounded px-2 py-0.5 text-xs font-medium ${priorityBadge[lead.priority] ?? priorityBadge.normal}`}>
154+
{lead.priority}
155+
</span>
156+
</td>
157+
<td className="px-4 py-3 text-sm text-slate-600">{lead.expected_close_date ?? '—'}</td>
158+
</tr>
159+
))}
160+
{recentLeads.length === 0 && (
161+
<tr>
162+
<td colSpan={5} className="px-4 py-8 text-center text-sm text-slate-400">No leads yet</td>
163+
</tr>
164+
)}
165+
</tbody>
166+
</table>
167+
</div>
168+
</div>
169+
</div>
170+
</div>
171+
</AppLayout>
172+
);
173+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { Head, useForm } from '@inertiajs/react';
2+
import AppLayout from '@/Layouts/AppLayout';
3+
import { Button } from '@/Components/Common/Button';
4+
import type { PageProps } from '@/types';
5+
6+
interface Stage { id: number; name: string; type: string }
7+
interface User { id: number; name: string }
8+
interface Props extends PageProps { stages: Stage[]; users: User[] }
9+
10+
const SOURCES = ['website','referral','cold_call','email','social_media','advertisement','other'];
11+
12+
export default function LeadsCreate({ stages, users }: Props) {
13+
const { data, setData, post, processing, errors } = useForm({
14+
title: '', type: 'lead', stage_id: '', contact_name: '', company_name: '',
15+
email: '', phone: '', website: '', source: '', expected_revenue: '',
16+
probability: '', expected_close_date: '', priority: 'normal',
17+
description: '', assigned_to: '',
18+
});
19+
20+
const inputCls = 'mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500';
21+
const labelCls = 'block text-sm font-medium text-slate-700';
22+
const errorCls = 'mt-1 text-xs text-red-600';
23+
24+
return (
25+
<AppLayout>
26+
<Head title="New Lead" />
27+
<div className="max-w-3xl space-y-6">
28+
<div>
29+
<h1 className="text-2xl font-semibold text-slate-900">New Lead</h1>
30+
</div>
31+
<form onSubmit={e => { e.preventDefault(); post('/crm/leads'); }} className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
32+
33+
<div className="grid grid-cols-2 gap-4">
34+
<div className="col-span-2">
35+
<label className={labelCls}>Title <span className="text-red-500">*</span></label>
36+
<input type="text" className={inputCls} value={data.title} onChange={e => setData('title', e.target.value)} required />
37+
{errors.title && <p className={errorCls}>{errors.title}</p>}
38+
</div>
39+
<div>
40+
<label className={labelCls}>Type</label>
41+
<select className={inputCls} value={data.type} onChange={e => setData('type', e.target.value)}>
42+
<option value="lead">Lead</option>
43+
<option value="opportunity">Opportunity</option>
44+
</select>
45+
</div>
46+
<div>
47+
<label className={labelCls}>Priority</label>
48+
<select className={inputCls} value={data.priority} onChange={e => setData('priority', e.target.value)}>
49+
<option value="low">Low</option>
50+
<option value="normal">Normal</option>
51+
<option value="high">High</option>
52+
<option value="urgent">Urgent</option>
53+
</select>
54+
</div>
55+
</div>
56+
57+
<div className="grid grid-cols-2 gap-4">
58+
<div>
59+
<label className={labelCls}>Contact Name</label>
60+
<input type="text" className={inputCls} value={data.contact_name} onChange={e => setData('contact_name', e.target.value)} />
61+
</div>
62+
<div>
63+
<label className={labelCls}>Company Name</label>
64+
<input type="text" className={inputCls} value={data.company_name} onChange={e => setData('company_name', e.target.value)} />
65+
</div>
66+
<div>
67+
<label className={labelCls}>Email</label>
68+
<input type="email" className={inputCls} value={data.email} onChange={e => setData('email', e.target.value)} />
69+
</div>
70+
<div>
71+
<label className={labelCls}>Phone</label>
72+
<input type="text" className={inputCls} value={data.phone} onChange={e => setData('phone', e.target.value)} />
73+
</div>
74+
</div>
75+
76+
<div className="grid grid-cols-2 gap-4">
77+
<div>
78+
<label className={labelCls}>Stage</label>
79+
<select className={inputCls} value={data.stage_id} onChange={e => setData('stage_id', e.target.value)}>
80+
<option value="">None</option>
81+
{stages.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
82+
</select>
83+
</div>
84+
<div>
85+
<label className={labelCls}>Assigned To</label>
86+
<select className={inputCls} value={data.assigned_to} onChange={e => setData('assigned_to', e.target.value)}>
87+
<option value="">Unassigned</option>
88+
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
89+
</select>
90+
</div>
91+
<div>
92+
<label className={labelCls}>Source</label>
93+
<select className={inputCls} value={data.source} onChange={e => setData('source', e.target.value)}>
94+
<option value="">None</option>
95+
{SOURCES.map(s => <option key={s} value={s}>{s.replace('_',' ')}</option>)}
96+
</select>
97+
</div>
98+
<div>
99+
<label className={labelCls}>Expected Close Date</label>
100+
<input type="date" className={inputCls} value={data.expected_close_date} onChange={e => setData('expected_close_date', e.target.value)} />
101+
</div>
102+
<div>
103+
<label className={labelCls}>Expected Revenue</label>
104+
<input type="number" min="0" step="0.01" className={inputCls} value={data.expected_revenue} onChange={e => setData('expected_revenue', e.target.value)} />
105+
</div>
106+
<div>
107+
<label className={labelCls}>Probability (%)</label>
108+
<input type="number" min="0" max="100" step="1" className={inputCls} value={data.probability} onChange={e => setData('probability', e.target.value)} />
109+
</div>
110+
</div>
111+
112+
<div>
113+
<label className={labelCls}>Description</label>
114+
<textarea rows={4} className={inputCls} value={data.description} onChange={e => setData('description', e.target.value)} />
115+
</div>
116+
117+
<div className="flex items-center justify-end gap-3 border-t border-slate-100 pt-4">
118+
<a href="/crm/leads" className="text-sm text-slate-600 hover:text-slate-800">Cancel</a>
119+
<Button type="submit" disabled={processing}>{processing ? 'Creating...' : 'Create Lead'}</Button>
120+
</div>
121+
</form>
122+
</div>
123+
</AppLayout>
124+
);
125+
}

0 commit comments

Comments
 (0)