@nextsparkjs/theme-crm 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CRM_PLAN.md +343 -0
- package/about.md +122 -0
- package/config/app.config.ts +185 -0
- package/config/billing.config.ts +187 -0
- package/config/dashboard.config.ts +372 -0
- package/config/dev.config.ts +55 -0
- package/config/features.config.ts +336 -0
- package/config/flows.config.ts +511 -0
- package/config/permissions.config.ts +297 -0
- package/config/theme.config.ts +111 -0
- package/entities/activities/activities.config.ts +61 -0
- package/entities/activities/activities.fields.ts +362 -0
- package/entities/activities/activities.service.ts +503 -0
- package/entities/activities/activities.types.ts +117 -0
- package/entities/activities/messages/en.json +123 -0
- package/entities/activities/messages/es.json +123 -0
- package/entities/activities/migrations/020_activities_table.sql +123 -0
- package/entities/activities/migrations/021_activities_metas.sql +114 -0
- package/entities/activities/migrations/022_activities_sample_data.sql +420 -0
- package/entities/campaigns/campaigns.config.ts +61 -0
- package/entities/campaigns/campaigns.fields.ts +413 -0
- package/entities/campaigns/campaigns.service.ts +426 -0
- package/entities/campaigns/campaigns.types.ts +124 -0
- package/entities/campaigns/messages/en.json +145 -0
- package/entities/campaigns/messages/es.json +145 -0
- package/entities/campaigns/migrations/001_campaigns_table.sql +127 -0
- package/entities/campaigns/migrations/002_campaigns_metas.sql +114 -0
- package/entities/campaigns/migrations/003_campaigns_sample_data.sql +364 -0
- package/entities/companies/companies.config.ts +61 -0
- package/entities/companies/companies.fields.ts +429 -0
- package/entities/companies/companies.service.ts +566 -0
- package/entities/companies/companies.types.ts +125 -0
- package/entities/companies/messages/en.json +146 -0
- package/entities/companies/messages/es.json +146 -0
- package/entities/companies/migrations/001_companies_table.sql +150 -0
- package/entities/companies/migrations/002_companies_metas.sql +114 -0
- package/entities/companies/migrations/003_companies_sample_data.sql +246 -0
- package/entities/contacts/contacts.config.ts +61 -0
- package/entities/contacts/contacts.fields.ts +359 -0
- package/entities/contacts/contacts.service.ts +509 -0
- package/entities/contacts/contacts.types.ts +108 -0
- package/entities/contacts/messages/en.json +117 -0
- package/entities/contacts/messages/es.json +117 -0
- package/entities/contacts/migrations/001_contacts_table.sql +134 -0
- package/entities/contacts/migrations/002_contacts_metas.sql +114 -0
- package/entities/contacts/migrations/003_contacts_sample_data.sql +421 -0
- package/entities/leads/leads.config.ts +61 -0
- package/entities/leads/leads.fields.ts +336 -0
- package/entities/leads/leads.service.ts +496 -0
- package/entities/leads/leads.types.ts +114 -0
- package/entities/leads/messages/en.json +132 -0
- package/entities/leads/messages/es.json +132 -0
- package/entities/leads/migrations/001_leads_table.sql +150 -0
- package/entities/leads/migrations/002_leads_metas.sql +120 -0
- package/entities/leads/migrations/003_leads_sample_data.sql +242 -0
- package/entities/notes/messages/en.json +114 -0
- package/entities/notes/messages/es.json +114 -0
- package/entities/notes/migrations/020_notes_table.sql +118 -0
- package/entities/notes/migrations/021_notes_metas.sql +114 -0
- package/entities/notes/migrations/022_notes_sample_data.sql +275 -0
- package/entities/notes/notes.config.ts +61 -0
- package/entities/notes/notes.fields.ts +283 -0
- package/entities/notes/notes.service.ts +320 -0
- package/entities/notes/notes.types.ts +102 -0
- package/entities/opportunities/messages/en.json +107 -0
- package/entities/opportunities/messages/es.json +107 -0
- package/entities/opportunities/migrations/010_opportunities_table.sql +145 -0
- package/entities/opportunities/migrations/011_opportunities_metas.sql +114 -0
- package/entities/opportunities/migrations/012_opportunities_sample_data.sql +438 -0
- package/entities/opportunities/opportunities.config.ts +61 -0
- package/entities/opportunities/opportunities.fields.ts +416 -0
- package/entities/opportunities/opportunities.service.ts +525 -0
- package/entities/opportunities/opportunities.types.ts +135 -0
- package/entities/pipelines/messages/en.json +115 -0
- package/entities/pipelines/messages/es.json +115 -0
- package/entities/pipelines/migrations/001_pipelines_table.sql +106 -0
- package/entities/pipelines/migrations/002_pipelines_metas.sql +114 -0
- package/entities/pipelines/migrations/003_pipelines_sample_data.sql +91 -0
- package/entities/pipelines/pipelines.config.ts +62 -0
- package/entities/pipelines/pipelines.fields.ts +193 -0
- package/entities/pipelines/pipelines.service.ts +383 -0
- package/entities/pipelines/pipelines.types.ts +78 -0
- package/entities/products/messages/en.json +135 -0
- package/entities/products/messages/es.json +135 -0
- package/entities/products/migrations/001_products_table.sql +117 -0
- package/entities/products/migrations/002_products_metas.sql +114 -0
- package/entities/products/migrations/003_products_sample_data.sql +247 -0
- package/entities/products/products.config.ts +62 -0
- package/entities/products/products.fields.ts +361 -0
- package/entities/products/products.service.ts +437 -0
- package/entities/products/products.types.ts +125 -0
- package/lib/crm-constants.ts +77 -0
- package/lib/crm-utils.ts +185 -0
- package/lib/selectors.ts +333 -0
- package/messages/en.json +131 -0
- package/messages/es.json +131 -0
- package/migrations/999_theme_sample_data.sql +473 -0
- package/package.json +18 -0
- package/pendings.md +205 -0
- package/permissions-matrix.md +216 -0
- package/styles/components.css +414 -0
- package/styles/crm-theme.css +358 -0
- package/styles/globals.css +576 -0
- package/styles/variables.css +111 -0
- package/templates/dashboard/(main)/activities/components/ActivityCard.tsx +169 -0
- package/templates/dashboard/(main)/activities/components/ActivityTimeline.tsx +165 -0
- package/templates/dashboard/(main)/activities/page.tsx +297 -0
- package/templates/dashboard/(main)/campaigns/page.tsx +373 -0
- package/templates/dashboard/(main)/companies/page.tsx +296 -0
- package/templates/dashboard/(main)/contacts/page.tsx +347 -0
- package/templates/dashboard/(main)/layout.tsx +98 -0
- package/templates/dashboard/(main)/leads/page.tsx +335 -0
- package/templates/dashboard/(main)/opportunities/[id]/edit/page.tsx +95 -0
- package/templates/dashboard/(main)/opportunities/create/page.tsx +94 -0
- package/templates/dashboard/(main)/opportunities/page.tsx +350 -0
- package/templates/dashboard/(main)/pipelines/[id]/edit/page.tsx +95 -0
- package/templates/dashboard/(main)/pipelines/[id]/page.tsx +143 -0
- package/templates/dashboard/(main)/pipelines/create/page.tsx +94 -0
- package/templates/dashboard/(main)/pipelines/page.tsx +234 -0
- package/templates/dashboard/(main)/products/[id]/edit/page.tsx +97 -0
- package/templates/dashboard/(main)/products/[id]/page.tsx +509 -0
- package/templates/dashboard/(main)/products/create/page.tsx +96 -0
- package/templates/dashboard/(main)/products/page.tsx +308 -0
- package/templates/shared/ActionButtons.tsx +41 -0
- package/templates/shared/CRMDashboard.tsx +519 -0
- package/templates/shared/CRMDataTable.tsx +441 -0
- package/templates/shared/CRMMetricCard.tsx +76 -0
- package/templates/shared/CRMMobileNav.tsx +172 -0
- package/templates/shared/CRMSidebar.tsx +346 -0
- package/templates/shared/CRMTopBar.tsx +265 -0
- package/templates/shared/DealCard.tsx +123 -0
- package/templates/shared/EntityCard.tsx +58 -0
- package/templates/shared/OpportunityForm.tsx +649 -0
- package/templates/shared/PipelineForm.tsx +367 -0
- package/templates/shared/PipelineKanban.tsx +194 -0
- package/templates/shared/QuickFilters.tsx +47 -0
- package/templates/shared/StageColumn.tsx +175 -0
- package/templates/shared/StageSelect.tsx +177 -0
- package/templates/shared/StagesRepeater.tsx +317 -0
- package/templates/shared/index.ts +9 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Campaigns Page
|
|
3
|
+
* Professional marketing campaigns management with data table
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client'
|
|
7
|
+
|
|
8
|
+
import { useRouter } from 'next/navigation'
|
|
9
|
+
import { useEffect, useState, useMemo } from 'react'
|
|
10
|
+
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
11
|
+
import { CRMDataTable, type Column, type BulkAction } from '@/themes/crm/templates/shared/CRMDataTable'
|
|
12
|
+
import {
|
|
13
|
+
Plus,
|
|
14
|
+
Megaphone,
|
|
15
|
+
Trash2,
|
|
16
|
+
Download,
|
|
17
|
+
Calendar,
|
|
18
|
+
Target,
|
|
19
|
+
Users,
|
|
20
|
+
TrendingUp,
|
|
21
|
+
Mail,
|
|
22
|
+
MousePointerClick,
|
|
23
|
+
DollarSign,
|
|
24
|
+
Pause,
|
|
25
|
+
Play
|
|
26
|
+
} from 'lucide-react'
|
|
27
|
+
import { fetchWithTeam } from '@nextsparkjs/core/lib/api/entities'
|
|
28
|
+
import { useTeamContext } from '@nextsparkjs/core/contexts/TeamContext'
|
|
29
|
+
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
30
|
+
import { PermissionGate } from '@nextsparkjs/core/components/permissions/PermissionGate'
|
|
31
|
+
import { usePermission } from '@nextsparkjs/core/lib/permissions/hooks'
|
|
32
|
+
|
|
33
|
+
interface Campaign {
|
|
34
|
+
id: string
|
|
35
|
+
name: string
|
|
36
|
+
description?: string
|
|
37
|
+
type: 'email' | 'social' | 'ads' | 'content' | 'event'
|
|
38
|
+
status: 'draft' | 'scheduled' | 'active' | 'paused' | 'completed'
|
|
39
|
+
startDate?: string
|
|
40
|
+
endDate?: string
|
|
41
|
+
budget?: number
|
|
42
|
+
spent?: number
|
|
43
|
+
leads?: number
|
|
44
|
+
conversions?: number
|
|
45
|
+
clickRate?: number
|
|
46
|
+
createdAt: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Campaign type badge
|
|
50
|
+
function TypeBadge({ type }: { type: Campaign['type'] }) {
|
|
51
|
+
const config = {
|
|
52
|
+
email: { label: 'Email', icon: Mail, className: 'bg-primary/10 text-primary' },
|
|
53
|
+
social: { label: 'Social', icon: Users, className: 'bg-violet-500/10 text-violet-600' },
|
|
54
|
+
ads: { label: 'Ads', icon: MousePointerClick, className: 'bg-amber-500/10 text-amber-600' },
|
|
55
|
+
content: { label: 'Content', icon: Megaphone, className: 'bg-emerald-500/10 text-emerald-600' },
|
|
56
|
+
event: { label: 'Event', icon: Calendar, className: 'bg-rose-500/10 text-rose-600' },
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { label, icon: Icon, className } = config[type] || config.email
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<span className={cn('inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium', className)}>
|
|
63
|
+
<Icon className="w-3 h-3" />
|
|
64
|
+
{label}
|
|
65
|
+
</span>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Status badge
|
|
70
|
+
function StatusBadge({ status }: { status: Campaign['status'] }) {
|
|
71
|
+
const config = {
|
|
72
|
+
draft: { label: 'Draft', className: 'bg-muted text-muted-foreground' },
|
|
73
|
+
scheduled: { label: 'Scheduled', className: 'bg-amber-500/10 text-amber-600' },
|
|
74
|
+
active: { label: 'Active', className: 'bg-emerald-500/10 text-emerald-600' },
|
|
75
|
+
paused: { label: 'Paused', className: 'bg-orange-500/10 text-orange-600' },
|
|
76
|
+
completed: { label: 'Completed', className: 'bg-primary/10 text-primary' },
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { label, className } = config[status] || config.draft
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<span className={cn('px-2.5 py-1 rounded-md text-xs font-medium', className)}>
|
|
83
|
+
{label}
|
|
84
|
+
</span>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Budget progress bar
|
|
89
|
+
function BudgetProgress({ budget, spent }: { budget?: number; spent?: number }) {
|
|
90
|
+
if (!budget) return <span className="text-muted-foreground">-</span>
|
|
91
|
+
|
|
92
|
+
const percentage = spent ? Math.min((spent / budget) * 100, 100) : 0
|
|
93
|
+
let barColor = 'bg-emerald-500'
|
|
94
|
+
if (percentage >= 90) barColor = 'bg-destructive'
|
|
95
|
+
else if (percentage >= 75) barColor = 'bg-amber-500'
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className="space-y-1">
|
|
99
|
+
<div className="flex items-center justify-between text-xs">
|
|
100
|
+
<span className="text-muted-foreground">
|
|
101
|
+
${(spent || 0).toLocaleString()} / ${budget.toLocaleString()}
|
|
102
|
+
</span>
|
|
103
|
+
</div>
|
|
104
|
+
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
|
105
|
+
<div
|
|
106
|
+
className={cn('h-full rounded-full transition-all', barColor)}
|
|
107
|
+
style={{ width: `${percentage}%` }}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default function CampaignsPage() {
|
|
115
|
+
const router = useRouter()
|
|
116
|
+
const { currentTeam, isLoading: teamLoading } = useTeamContext()
|
|
117
|
+
const [campaigns, setCampaigns] = useState<Campaign[]>([])
|
|
118
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
119
|
+
|
|
120
|
+
// Permission checks for bulk actions
|
|
121
|
+
const canDeleteCampaigns = usePermission('campaigns.delete')
|
|
122
|
+
const canUpdateCampaigns = usePermission('campaigns.update')
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (teamLoading || !currentTeam) return
|
|
126
|
+
|
|
127
|
+
async function fetchCampaigns() {
|
|
128
|
+
try {
|
|
129
|
+
const response = await fetchWithTeam('/api/v1/campaigns')
|
|
130
|
+
if (!response.ok) throw new Error('Failed to fetch campaigns')
|
|
131
|
+
const result = await response.json()
|
|
132
|
+
setCampaigns(result.data || [])
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error('Error loading campaigns:', error)
|
|
135
|
+
} finally {
|
|
136
|
+
setIsLoading(false)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
fetchCampaigns()
|
|
141
|
+
}, [teamLoading, currentTeam])
|
|
142
|
+
|
|
143
|
+
// Stats
|
|
144
|
+
const stats = useMemo(() => {
|
|
145
|
+
const active = campaigns.filter(c => c.status === 'active').length
|
|
146
|
+
const totalLeads = campaigns.reduce((sum, c) => sum + (c.leads || 0), 0)
|
|
147
|
+
const totalSpent = campaigns.reduce((sum, c) => sum + (c.spent || 0), 0)
|
|
148
|
+
const totalConversions = campaigns.reduce((sum, c) => sum + (c.conversions || 0), 0)
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
total: campaigns.length,
|
|
152
|
+
active,
|
|
153
|
+
totalLeads,
|
|
154
|
+
totalSpent,
|
|
155
|
+
totalConversions,
|
|
156
|
+
}
|
|
157
|
+
}, [campaigns])
|
|
158
|
+
|
|
159
|
+
const formatCurrency = (value: number) => {
|
|
160
|
+
return new Intl.NumberFormat('en-US', {
|
|
161
|
+
style: 'currency',
|
|
162
|
+
currency: 'USD',
|
|
163
|
+
minimumFractionDigits: 0,
|
|
164
|
+
maximumFractionDigits: 0,
|
|
165
|
+
}).format(value)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const formatDate = (date?: string) => {
|
|
169
|
+
if (!date) return '-'
|
|
170
|
+
return new Date(date).toLocaleDateString('en-US', {
|
|
171
|
+
month: 'short',
|
|
172
|
+
day: 'numeric',
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Column definitions
|
|
177
|
+
const columns: Column<Campaign>[] = [
|
|
178
|
+
{
|
|
179
|
+
key: 'name',
|
|
180
|
+
header: 'Campaign',
|
|
181
|
+
sortable: true,
|
|
182
|
+
render: (_, campaign) => (
|
|
183
|
+
<div className="flex items-center gap-3">
|
|
184
|
+
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
185
|
+
<Megaphone className="w-4 h-4 text-primary" />
|
|
186
|
+
</div>
|
|
187
|
+
<div className="min-w-0">
|
|
188
|
+
<p className="font-medium text-foreground truncate">{campaign.name}</p>
|
|
189
|
+
{campaign.description && (
|
|
190
|
+
<p className="text-xs text-muted-foreground truncate max-w-[200px]">
|
|
191
|
+
{campaign.description}
|
|
192
|
+
</p>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
),
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
key: 'type',
|
|
200
|
+
header: 'Type',
|
|
201
|
+
sortable: true,
|
|
202
|
+
render: (value) => <TypeBadge type={value} />,
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
key: 'status',
|
|
206
|
+
header: 'Status',
|
|
207
|
+
sortable: true,
|
|
208
|
+
render: (value) => <StatusBadge status={value} />,
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
key: 'dates',
|
|
212
|
+
header: 'Duration',
|
|
213
|
+
render: (_, campaign) => (
|
|
214
|
+
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
215
|
+
<Calendar className="w-3.5 h-3.5" />
|
|
216
|
+
<span>
|
|
217
|
+
{formatDate(campaign.startDate)} - {formatDate(campaign.endDate)}
|
|
218
|
+
</span>
|
|
219
|
+
</div>
|
|
220
|
+
),
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
key: 'budget',
|
|
224
|
+
header: 'Budget',
|
|
225
|
+
sortable: true,
|
|
226
|
+
render: (_, campaign) => (
|
|
227
|
+
<BudgetProgress budget={campaign.budget} spent={campaign.spent} />
|
|
228
|
+
),
|
|
229
|
+
width: '150px',
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
key: 'leads',
|
|
233
|
+
header: 'Leads',
|
|
234
|
+
sortable: true,
|
|
235
|
+
render: (value) => (
|
|
236
|
+
<div className="flex items-center gap-1.5 text-sm">
|
|
237
|
+
<Target className="w-3.5 h-3.5 text-muted-foreground" />
|
|
238
|
+
<span className="font-medium">{value || 0}</span>
|
|
239
|
+
</div>
|
|
240
|
+
),
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
key: 'conversions',
|
|
244
|
+
header: 'Conversions',
|
|
245
|
+
sortable: true,
|
|
246
|
+
render: (value) => (
|
|
247
|
+
<div className="flex items-center gap-1.5 text-sm">
|
|
248
|
+
<TrendingUp className="w-3.5 h-3.5 text-emerald-600" />
|
|
249
|
+
<span className="font-medium text-emerald-600">{value || 0}</span>
|
|
250
|
+
</div>
|
|
251
|
+
),
|
|
252
|
+
},
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
// Bulk actions - filtered by permissions
|
|
256
|
+
const bulkActions: BulkAction[] = [
|
|
257
|
+
{
|
|
258
|
+
id: 'export',
|
|
259
|
+
label: 'Export',
|
|
260
|
+
icon: <Download className="w-4 h-4" />,
|
|
261
|
+
onClick: (ids) => console.log('Export:', ids),
|
|
262
|
+
},
|
|
263
|
+
// Only show pause action if user has update permission
|
|
264
|
+
...(canUpdateCampaigns ? [{
|
|
265
|
+
id: 'pause',
|
|
266
|
+
label: 'Pause',
|
|
267
|
+
icon: <Pause className="w-4 h-4" />,
|
|
268
|
+
onClick: (ids: string[]) => { if (confirm(`Pause ${ids.length} campaigns?`)) console.log('Pause:', ids) },
|
|
269
|
+
}] : []),
|
|
270
|
+
// Only show delete action if user has permission
|
|
271
|
+
...(canDeleteCampaigns ? [{
|
|
272
|
+
id: 'delete',
|
|
273
|
+
label: 'Delete',
|
|
274
|
+
icon: <Trash2 className="w-4 h-4" />,
|
|
275
|
+
variant: 'destructive' as const,
|
|
276
|
+
onClick: (ids: string[]) => { if (confirm(`Delete ${ids.length} campaigns?`)) console.log('Delete:', ids) },
|
|
277
|
+
}] : []),
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<div className="p-6 space-y-6">
|
|
282
|
+
{/* Header */}
|
|
283
|
+
<div className="flex items-start justify-between">
|
|
284
|
+
<div>
|
|
285
|
+
<h1 className="text-2xl font-bold text-foreground tracking-tight">Campaigns</h1>
|
|
286
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
287
|
+
Manage and track your marketing campaigns
|
|
288
|
+
</p>
|
|
289
|
+
</div>
|
|
290
|
+
<PermissionGate permission="campaigns.create">
|
|
291
|
+
<Button onClick={() => router.push('/dashboard/campaigns/create')} className="gap-2" data-cy="campaigns-add">
|
|
292
|
+
<Plus className="w-4 h-4" />
|
|
293
|
+
New Campaign
|
|
294
|
+
</Button>
|
|
295
|
+
</PermissionGate>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
{/* Stats */}
|
|
299
|
+
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
|
300
|
+
<div className="bg-card border rounded-xl p-4">
|
|
301
|
+
<div className="flex items-center gap-3">
|
|
302
|
+
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
303
|
+
<Megaphone className="w-5 h-5 text-primary" />
|
|
304
|
+
</div>
|
|
305
|
+
<div>
|
|
306
|
+
<p className="text-2xl font-bold text-foreground">{stats.total}</p>
|
|
307
|
+
<p className="text-xs text-muted-foreground">Campaigns</p>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
<div className="bg-card border rounded-xl p-4">
|
|
312
|
+
<div className="flex items-center gap-3">
|
|
313
|
+
<div className="w-10 h-10 rounded-lg bg-emerald-500/10 flex items-center justify-center">
|
|
314
|
+
<Play className="w-5 h-5 text-emerald-600" />
|
|
315
|
+
</div>
|
|
316
|
+
<div>
|
|
317
|
+
<p className="text-2xl font-bold text-foreground">{stats.active}</p>
|
|
318
|
+
<p className="text-xs text-muted-foreground">Active</p>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
<div className="bg-card border rounded-xl p-4">
|
|
323
|
+
<div className="flex items-center gap-3">
|
|
324
|
+
<div className="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center">
|
|
325
|
+
<Target className="w-5 h-5 text-amber-600" />
|
|
326
|
+
</div>
|
|
327
|
+
<div>
|
|
328
|
+
<p className="text-2xl font-bold text-foreground">{stats.totalLeads}</p>
|
|
329
|
+
<p className="text-xs text-muted-foreground">Leads</p>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
<div className="bg-card border rounded-xl p-4">
|
|
334
|
+
<div className="flex items-center gap-3">
|
|
335
|
+
<div className="w-10 h-10 rounded-lg bg-violet-500/10 flex items-center justify-center">
|
|
336
|
+
<TrendingUp className="w-5 h-5 text-violet-600" />
|
|
337
|
+
</div>
|
|
338
|
+
<div>
|
|
339
|
+
<p className="text-2xl font-bold text-foreground">{stats.totalConversions}</p>
|
|
340
|
+
<p className="text-xs text-muted-foreground">Conversions</p>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
<div className="bg-card border rounded-xl p-4">
|
|
345
|
+
<div className="flex items-center gap-3">
|
|
346
|
+
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center">
|
|
347
|
+
<DollarSign className="w-5 h-5 text-muted-foreground" />
|
|
348
|
+
</div>
|
|
349
|
+
<div>
|
|
350
|
+
<p className="text-2xl font-bold text-foreground">{formatCurrency(stats.totalSpent)}</p>
|
|
351
|
+
<p className="text-xs text-muted-foreground">Spent</p>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
{/* Data Table */}
|
|
358
|
+
<CRMDataTable
|
|
359
|
+
data={campaigns}
|
|
360
|
+
columns={columns}
|
|
361
|
+
bulkActions={bulkActions}
|
|
362
|
+
onRowClick={(c) => router.push(`/dashboard/campaigns/${c.id}`)}
|
|
363
|
+
isLoading={isLoading}
|
|
364
|
+
searchPlaceholder="Search campaigns..."
|
|
365
|
+
searchFields={['name', 'description', 'type']}
|
|
366
|
+
pageSize={15}
|
|
367
|
+
emptyMessage="No campaigns yet"
|
|
368
|
+
emptyDescription="Start creating campaigns to track your marketing efforts."
|
|
369
|
+
entitySlug="campaigns"
|
|
370
|
+
/>
|
|
371
|
+
</div>
|
|
372
|
+
)
|
|
373
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Companies Page
|
|
3
|
+
* Professional companies management with data table
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client'
|
|
7
|
+
|
|
8
|
+
import { useRouter } from 'next/navigation'
|
|
9
|
+
import { useEffect, useState, useMemo } from 'react'
|
|
10
|
+
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
11
|
+
import { CRMDataTable, type Column, type BulkAction } from '@/themes/crm/templates/shared/CRMDataTable'
|
|
12
|
+
import {
|
|
13
|
+
Plus,
|
|
14
|
+
Building2,
|
|
15
|
+
Trash2,
|
|
16
|
+
Download,
|
|
17
|
+
Globe,
|
|
18
|
+
Users,
|
|
19
|
+
Target,
|
|
20
|
+
DollarSign
|
|
21
|
+
} from 'lucide-react'
|
|
22
|
+
import { fetchWithTeam } from '@nextsparkjs/core/lib/api/entities'
|
|
23
|
+
import { useTeamContext } from '@nextsparkjs/core/contexts/TeamContext'
|
|
24
|
+
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
25
|
+
import { PermissionGate } from '@nextsparkjs/core/components/permissions/PermissionGate'
|
|
26
|
+
import { usePermission } from '@nextsparkjs/core/lib/permissions/hooks'
|
|
27
|
+
|
|
28
|
+
interface Company {
|
|
29
|
+
id: string
|
|
30
|
+
name: string
|
|
31
|
+
industry?: string
|
|
32
|
+
website?: string
|
|
33
|
+
size?: 'startup' | 'small' | 'medium' | 'enterprise'
|
|
34
|
+
employeeCount?: number
|
|
35
|
+
contactCount?: number
|
|
36
|
+
opportunityCount?: number
|
|
37
|
+
totalPipelineValue?: number
|
|
38
|
+
createdAt: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Size badge component
|
|
42
|
+
function SizeBadge({ size }: { size?: Company['size'] | string }) {
|
|
43
|
+
const config: Record<string, { label: string; className: string }> = {
|
|
44
|
+
startup: { label: 'Startup', className: 'bg-violet-500/10 text-violet-600' },
|
|
45
|
+
small: { label: 'Small', className: 'bg-amber-500/10 text-amber-600' },
|
|
46
|
+
medium: { label: 'Medium', className: 'bg-emerald-500/10 text-emerald-600' },
|
|
47
|
+
enterprise: { label: 'Enterprise', className: 'bg-primary/10 text-primary' },
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!size) return <span className="text-muted-foreground">-</span>
|
|
51
|
+
|
|
52
|
+
const sizeConfig = config[size]
|
|
53
|
+
if (!sizeConfig) {
|
|
54
|
+
// Fallback for unknown size values
|
|
55
|
+
return (
|
|
56
|
+
<span className={cn('px-2.5 py-1 rounded-md text-xs font-medium bg-muted text-muted-foreground capitalize')}>
|
|
57
|
+
{size}
|
|
58
|
+
</span>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<span className={cn('px-2.5 py-1 rounded-md text-xs font-medium', sizeConfig.className)}>
|
|
64
|
+
{sizeConfig.label}
|
|
65
|
+
</span>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default function CompaniesPage() {
|
|
70
|
+
const router = useRouter()
|
|
71
|
+
const { currentTeam, isLoading: teamLoading } = useTeamContext()
|
|
72
|
+
const [companies, setCompanies] = useState<Company[]>([])
|
|
73
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
74
|
+
|
|
75
|
+
// Permission checks for bulk actions
|
|
76
|
+
const canDeleteCompanies = usePermission('companies.delete')
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (teamLoading || !currentTeam) return
|
|
80
|
+
|
|
81
|
+
async function fetchCompanies() {
|
|
82
|
+
try {
|
|
83
|
+
const response = await fetchWithTeam('/api/v1/companies')
|
|
84
|
+
if (!response.ok) throw new Error('Failed to fetch companies')
|
|
85
|
+
const result = await response.json()
|
|
86
|
+
setCompanies(result.data || [])
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error('Error loading companies:', error)
|
|
89
|
+
} finally {
|
|
90
|
+
setIsLoading(false)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fetchCompanies()
|
|
95
|
+
}, [teamLoading, currentTeam])
|
|
96
|
+
|
|
97
|
+
// Stats
|
|
98
|
+
const stats = useMemo(() => {
|
|
99
|
+
const totalContacts = companies.reduce((sum, c) => sum + (c.contactCount || 0), 0)
|
|
100
|
+
const totalOpportunities = companies.reduce((sum, c) => sum + (c.opportunityCount || 0), 0)
|
|
101
|
+
const totalPipeline = companies.reduce((sum, c) => sum + (c.totalPipelineValue || 0), 0)
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
total: companies.length,
|
|
105
|
+
totalContacts,
|
|
106
|
+
totalOpportunities,
|
|
107
|
+
totalPipeline,
|
|
108
|
+
}
|
|
109
|
+
}, [companies])
|
|
110
|
+
|
|
111
|
+
const formatCurrency = (value: number) => {
|
|
112
|
+
return new Intl.NumberFormat('en-US', {
|
|
113
|
+
style: 'currency',
|
|
114
|
+
currency: 'USD',
|
|
115
|
+
minimumFractionDigits: 0,
|
|
116
|
+
maximumFractionDigits: 0,
|
|
117
|
+
}).format(value)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Column definitions
|
|
121
|
+
const columns: Column<Company>[] = [
|
|
122
|
+
{
|
|
123
|
+
key: 'name',
|
|
124
|
+
header: 'Company',
|
|
125
|
+
sortable: true,
|
|
126
|
+
render: (_, company) => (
|
|
127
|
+
<div className="flex items-center gap-3">
|
|
128
|
+
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
|
|
129
|
+
{company.name?.[0]?.toUpperCase()}
|
|
130
|
+
</div>
|
|
131
|
+
<div>
|
|
132
|
+
<p className="font-medium text-foreground">{company.name}</p>
|
|
133
|
+
{company.industry && (
|
|
134
|
+
<p className="text-xs text-muted-foreground">{company.industry}</p>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
key: 'website',
|
|
142
|
+
header: 'Website',
|
|
143
|
+
render: (value) => value ? (
|
|
144
|
+
<a
|
|
145
|
+
href={value.startsWith('http') ? value : `https://${value}`}
|
|
146
|
+
target="_blank"
|
|
147
|
+
rel="noopener noreferrer"
|
|
148
|
+
onClick={(e) => e.stopPropagation()}
|
|
149
|
+
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-primary transition-colors"
|
|
150
|
+
>
|
|
151
|
+
<Globe className="w-3.5 h-3.5" />
|
|
152
|
+
<span className="truncate max-w-[150px]">{value.replace(/^https?:\/\//, '')}</span>
|
|
153
|
+
</a>
|
|
154
|
+
) : <span className="text-muted-foreground">-</span>,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
key: 'size',
|
|
158
|
+
header: 'Size',
|
|
159
|
+
sortable: true,
|
|
160
|
+
render: (value) => <SizeBadge size={value} />,
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
key: 'contactCount',
|
|
164
|
+
header: 'Contacts',
|
|
165
|
+
sortable: true,
|
|
166
|
+
render: (value) => (
|
|
167
|
+
<div className="flex items-center gap-1.5 text-sm">
|
|
168
|
+
<Users className="w-3.5 h-3.5 text-muted-foreground" />
|
|
169
|
+
<span>{value || 0}</span>
|
|
170
|
+
</div>
|
|
171
|
+
),
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
key: 'opportunityCount',
|
|
175
|
+
header: 'Opportunities',
|
|
176
|
+
sortable: true,
|
|
177
|
+
render: (value) => (
|
|
178
|
+
<div className="flex items-center gap-1.5 text-sm">
|
|
179
|
+
<Target className="w-3.5 h-3.5 text-muted-foreground" />
|
|
180
|
+
<span>{value || 0}</span>
|
|
181
|
+
</div>
|
|
182
|
+
),
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
key: 'totalPipelineValue',
|
|
186
|
+
header: 'Pipeline Value',
|
|
187
|
+
sortable: true,
|
|
188
|
+
render: (value) => (
|
|
189
|
+
<span className="font-medium text-primary">
|
|
190
|
+
{value ? formatCurrency(value) : '-'}
|
|
191
|
+
</span>
|
|
192
|
+
),
|
|
193
|
+
},
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
// Bulk actions - filtered by permissions
|
|
197
|
+
const bulkActions: BulkAction[] = [
|
|
198
|
+
{
|
|
199
|
+
id: 'export',
|
|
200
|
+
label: 'Export',
|
|
201
|
+
icon: <Download className="w-4 h-4" />,
|
|
202
|
+
onClick: (ids) => console.log('Export:', ids),
|
|
203
|
+
},
|
|
204
|
+
// Only show delete action if user has permission
|
|
205
|
+
...(canDeleteCompanies ? [{
|
|
206
|
+
id: 'delete',
|
|
207
|
+
label: 'Delete',
|
|
208
|
+
icon: <Trash2 className="w-4 h-4" />,
|
|
209
|
+
variant: 'destructive' as const,
|
|
210
|
+
onClick: (ids: string[]) => { if (confirm(`Delete ${ids.length} companies?`)) console.log('Delete:', ids) },
|
|
211
|
+
}] : []),
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div className="p-6 space-y-6">
|
|
216
|
+
{/* Header */}
|
|
217
|
+
<div className="flex items-start justify-between">
|
|
218
|
+
<div>
|
|
219
|
+
<h1 className="text-2xl font-bold text-foreground tracking-tight">Companies</h1>
|
|
220
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
221
|
+
Manage your business accounts and organizations
|
|
222
|
+
</p>
|
|
223
|
+
</div>
|
|
224
|
+
<PermissionGate permission="companies.create">
|
|
225
|
+
<Button onClick={() => router.push('/dashboard/companies/create')} className="gap-2" data-cy="companies-add">
|
|
226
|
+
<Plus className="w-4 h-4" />
|
|
227
|
+
Add Company
|
|
228
|
+
</Button>
|
|
229
|
+
</PermissionGate>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{/* Stats */}
|
|
233
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
234
|
+
<div className="bg-card border rounded-xl p-4">
|
|
235
|
+
<div className="flex items-center gap-3">
|
|
236
|
+
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
237
|
+
<Building2 className="w-5 h-5 text-primary" />
|
|
238
|
+
</div>
|
|
239
|
+
<div>
|
|
240
|
+
<p className="text-2xl font-bold text-foreground">{stats.total}</p>
|
|
241
|
+
<p className="text-xs text-muted-foreground">Companies</p>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
<div className="bg-card border rounded-xl p-4">
|
|
246
|
+
<div className="flex items-center gap-3">
|
|
247
|
+
<div className="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center">
|
|
248
|
+
<Users className="w-5 h-5 text-amber-600" />
|
|
249
|
+
</div>
|
|
250
|
+
<div>
|
|
251
|
+
<p className="text-2xl font-bold text-foreground">{stats.totalContacts}</p>
|
|
252
|
+
<p className="text-xs text-muted-foreground">Contacts</p>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
<div className="bg-card border rounded-xl p-4">
|
|
257
|
+
<div className="flex items-center gap-3">
|
|
258
|
+
<div className="w-10 h-10 rounded-lg bg-emerald-500/10 flex items-center justify-center">
|
|
259
|
+
<Target className="w-5 h-5 text-emerald-600" />
|
|
260
|
+
</div>
|
|
261
|
+
<div>
|
|
262
|
+
<p className="text-2xl font-bold text-foreground">{stats.totalOpportunities}</p>
|
|
263
|
+
<p className="text-xs text-muted-foreground">Opportunities</p>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
<div className="bg-card border rounded-xl p-4">
|
|
268
|
+
<div className="flex items-center gap-3">
|
|
269
|
+
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center">
|
|
270
|
+
<DollarSign className="w-5 h-5 text-muted-foreground" />
|
|
271
|
+
</div>
|
|
272
|
+
<div>
|
|
273
|
+
<p className="text-2xl font-bold text-foreground">{formatCurrency(stats.totalPipeline)}</p>
|
|
274
|
+
<p className="text-xs text-muted-foreground">Pipeline Value</p>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
{/* Data Table */}
|
|
281
|
+
<CRMDataTable
|
|
282
|
+
data={companies}
|
|
283
|
+
columns={columns}
|
|
284
|
+
bulkActions={bulkActions}
|
|
285
|
+
onRowClick={(c) => router.push(`/dashboard/companies/${c.id}`)}
|
|
286
|
+
isLoading={isLoading}
|
|
287
|
+
searchPlaceholder="Search companies..."
|
|
288
|
+
searchFields={['name', 'industry', 'website']}
|
|
289
|
+
pageSize={15}
|
|
290
|
+
emptyMessage="No companies yet"
|
|
291
|
+
emptyDescription="Start adding companies to manage your accounts."
|
|
292
|
+
entitySlug="companies"
|
|
293
|
+
/>
|
|
294
|
+
</div>
|
|
295
|
+
)
|
|
296
|
+
}
|