@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.
Files changed (140) hide show
  1. package/CRM_PLAN.md +343 -0
  2. package/about.md +122 -0
  3. package/config/app.config.ts +185 -0
  4. package/config/billing.config.ts +187 -0
  5. package/config/dashboard.config.ts +372 -0
  6. package/config/dev.config.ts +55 -0
  7. package/config/features.config.ts +336 -0
  8. package/config/flows.config.ts +511 -0
  9. package/config/permissions.config.ts +297 -0
  10. package/config/theme.config.ts +111 -0
  11. package/entities/activities/activities.config.ts +61 -0
  12. package/entities/activities/activities.fields.ts +362 -0
  13. package/entities/activities/activities.service.ts +503 -0
  14. package/entities/activities/activities.types.ts +117 -0
  15. package/entities/activities/messages/en.json +123 -0
  16. package/entities/activities/messages/es.json +123 -0
  17. package/entities/activities/migrations/020_activities_table.sql +123 -0
  18. package/entities/activities/migrations/021_activities_metas.sql +114 -0
  19. package/entities/activities/migrations/022_activities_sample_data.sql +420 -0
  20. package/entities/campaigns/campaigns.config.ts +61 -0
  21. package/entities/campaigns/campaigns.fields.ts +413 -0
  22. package/entities/campaigns/campaigns.service.ts +426 -0
  23. package/entities/campaigns/campaigns.types.ts +124 -0
  24. package/entities/campaigns/messages/en.json +145 -0
  25. package/entities/campaigns/messages/es.json +145 -0
  26. package/entities/campaigns/migrations/001_campaigns_table.sql +127 -0
  27. package/entities/campaigns/migrations/002_campaigns_metas.sql +114 -0
  28. package/entities/campaigns/migrations/003_campaigns_sample_data.sql +364 -0
  29. package/entities/companies/companies.config.ts +61 -0
  30. package/entities/companies/companies.fields.ts +429 -0
  31. package/entities/companies/companies.service.ts +566 -0
  32. package/entities/companies/companies.types.ts +125 -0
  33. package/entities/companies/messages/en.json +146 -0
  34. package/entities/companies/messages/es.json +146 -0
  35. package/entities/companies/migrations/001_companies_table.sql +150 -0
  36. package/entities/companies/migrations/002_companies_metas.sql +114 -0
  37. package/entities/companies/migrations/003_companies_sample_data.sql +246 -0
  38. package/entities/contacts/contacts.config.ts +61 -0
  39. package/entities/contacts/contacts.fields.ts +359 -0
  40. package/entities/contacts/contacts.service.ts +509 -0
  41. package/entities/contacts/contacts.types.ts +108 -0
  42. package/entities/contacts/messages/en.json +117 -0
  43. package/entities/contacts/messages/es.json +117 -0
  44. package/entities/contacts/migrations/001_contacts_table.sql +134 -0
  45. package/entities/contacts/migrations/002_contacts_metas.sql +114 -0
  46. package/entities/contacts/migrations/003_contacts_sample_data.sql +421 -0
  47. package/entities/leads/leads.config.ts +61 -0
  48. package/entities/leads/leads.fields.ts +336 -0
  49. package/entities/leads/leads.service.ts +496 -0
  50. package/entities/leads/leads.types.ts +114 -0
  51. package/entities/leads/messages/en.json +132 -0
  52. package/entities/leads/messages/es.json +132 -0
  53. package/entities/leads/migrations/001_leads_table.sql +150 -0
  54. package/entities/leads/migrations/002_leads_metas.sql +120 -0
  55. package/entities/leads/migrations/003_leads_sample_data.sql +242 -0
  56. package/entities/notes/messages/en.json +114 -0
  57. package/entities/notes/messages/es.json +114 -0
  58. package/entities/notes/migrations/020_notes_table.sql +118 -0
  59. package/entities/notes/migrations/021_notes_metas.sql +114 -0
  60. package/entities/notes/migrations/022_notes_sample_data.sql +275 -0
  61. package/entities/notes/notes.config.ts +61 -0
  62. package/entities/notes/notes.fields.ts +283 -0
  63. package/entities/notes/notes.service.ts +320 -0
  64. package/entities/notes/notes.types.ts +102 -0
  65. package/entities/opportunities/messages/en.json +107 -0
  66. package/entities/opportunities/messages/es.json +107 -0
  67. package/entities/opportunities/migrations/010_opportunities_table.sql +145 -0
  68. package/entities/opportunities/migrations/011_opportunities_metas.sql +114 -0
  69. package/entities/opportunities/migrations/012_opportunities_sample_data.sql +438 -0
  70. package/entities/opportunities/opportunities.config.ts +61 -0
  71. package/entities/opportunities/opportunities.fields.ts +416 -0
  72. package/entities/opportunities/opportunities.service.ts +525 -0
  73. package/entities/opportunities/opportunities.types.ts +135 -0
  74. package/entities/pipelines/messages/en.json +115 -0
  75. package/entities/pipelines/messages/es.json +115 -0
  76. package/entities/pipelines/migrations/001_pipelines_table.sql +106 -0
  77. package/entities/pipelines/migrations/002_pipelines_metas.sql +114 -0
  78. package/entities/pipelines/migrations/003_pipelines_sample_data.sql +91 -0
  79. package/entities/pipelines/pipelines.config.ts +62 -0
  80. package/entities/pipelines/pipelines.fields.ts +193 -0
  81. package/entities/pipelines/pipelines.service.ts +383 -0
  82. package/entities/pipelines/pipelines.types.ts +78 -0
  83. package/entities/products/messages/en.json +135 -0
  84. package/entities/products/messages/es.json +135 -0
  85. package/entities/products/migrations/001_products_table.sql +117 -0
  86. package/entities/products/migrations/002_products_metas.sql +114 -0
  87. package/entities/products/migrations/003_products_sample_data.sql +247 -0
  88. package/entities/products/products.config.ts +62 -0
  89. package/entities/products/products.fields.ts +361 -0
  90. package/entities/products/products.service.ts +437 -0
  91. package/entities/products/products.types.ts +125 -0
  92. package/lib/crm-constants.ts +77 -0
  93. package/lib/crm-utils.ts +185 -0
  94. package/lib/selectors.ts +333 -0
  95. package/messages/en.json +131 -0
  96. package/messages/es.json +131 -0
  97. package/migrations/999_theme_sample_data.sql +473 -0
  98. package/package.json +18 -0
  99. package/pendings.md +205 -0
  100. package/permissions-matrix.md +216 -0
  101. package/styles/components.css +414 -0
  102. package/styles/crm-theme.css +358 -0
  103. package/styles/globals.css +576 -0
  104. package/styles/variables.css +111 -0
  105. package/templates/dashboard/(main)/activities/components/ActivityCard.tsx +169 -0
  106. package/templates/dashboard/(main)/activities/components/ActivityTimeline.tsx +165 -0
  107. package/templates/dashboard/(main)/activities/page.tsx +297 -0
  108. package/templates/dashboard/(main)/campaigns/page.tsx +373 -0
  109. package/templates/dashboard/(main)/companies/page.tsx +296 -0
  110. package/templates/dashboard/(main)/contacts/page.tsx +347 -0
  111. package/templates/dashboard/(main)/layout.tsx +98 -0
  112. package/templates/dashboard/(main)/leads/page.tsx +335 -0
  113. package/templates/dashboard/(main)/opportunities/[id]/edit/page.tsx +95 -0
  114. package/templates/dashboard/(main)/opportunities/create/page.tsx +94 -0
  115. package/templates/dashboard/(main)/opportunities/page.tsx +350 -0
  116. package/templates/dashboard/(main)/pipelines/[id]/edit/page.tsx +95 -0
  117. package/templates/dashboard/(main)/pipelines/[id]/page.tsx +143 -0
  118. package/templates/dashboard/(main)/pipelines/create/page.tsx +94 -0
  119. package/templates/dashboard/(main)/pipelines/page.tsx +234 -0
  120. package/templates/dashboard/(main)/products/[id]/edit/page.tsx +97 -0
  121. package/templates/dashboard/(main)/products/[id]/page.tsx +509 -0
  122. package/templates/dashboard/(main)/products/create/page.tsx +96 -0
  123. package/templates/dashboard/(main)/products/page.tsx +308 -0
  124. package/templates/shared/ActionButtons.tsx +41 -0
  125. package/templates/shared/CRMDashboard.tsx +519 -0
  126. package/templates/shared/CRMDataTable.tsx +441 -0
  127. package/templates/shared/CRMMetricCard.tsx +76 -0
  128. package/templates/shared/CRMMobileNav.tsx +172 -0
  129. package/templates/shared/CRMSidebar.tsx +346 -0
  130. package/templates/shared/CRMTopBar.tsx +265 -0
  131. package/templates/shared/DealCard.tsx +123 -0
  132. package/templates/shared/EntityCard.tsx +58 -0
  133. package/templates/shared/OpportunityForm.tsx +649 -0
  134. package/templates/shared/PipelineForm.tsx +367 -0
  135. package/templates/shared/PipelineKanban.tsx +194 -0
  136. package/templates/shared/QuickFilters.tsx +47 -0
  137. package/templates/shared/StageColumn.tsx +175 -0
  138. package/templates/shared/StageSelect.tsx +177 -0
  139. package/templates/shared/StagesRepeater.tsx +317 -0
  140. 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
+ }