@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,509 @@
1
+ /**
2
+ * Product Detail Page
3
+ * Professional product detail view with CRM-style metrics and layout
4
+ */
5
+
6
+ 'use client'
7
+
8
+ import { useRouter, useParams } from 'next/navigation'
9
+ import { useEffect, useState, useMemo } from 'react'
10
+ import { Button } from '@nextsparkjs/core/components/ui/button'
11
+ import { Card, CardContent, CardHeader, CardTitle } from '@nextsparkjs/core/components/ui/card'
12
+ import {
13
+ ArrowLeft,
14
+ Package,
15
+ Edit,
16
+ Trash2,
17
+ DollarSign,
18
+ Percent,
19
+ Calculator,
20
+ ExternalLink,
21
+ FileText,
22
+ Image as ImageIcon,
23
+ CheckCircle,
24
+ XCircle,
25
+ Clock,
26
+ Tag,
27
+ Hash,
28
+ Layers,
29
+ Scale,
30
+ Users,
31
+ Calendar
32
+ } from 'lucide-react'
33
+ import { fetchWithTeam } from '@nextsparkjs/core/lib/api/entities'
34
+ import { useTeamContext } from '@nextsparkjs/core/contexts/TeamContext'
35
+ import { usePermission } from '@nextsparkjs/core/lib/permissions/hooks'
36
+ import { cn } from '@nextsparkjs/core/lib/utils'
37
+ import {
38
+ AlertDialog,
39
+ AlertDialogAction,
40
+ AlertDialogCancel,
41
+ AlertDialogContent,
42
+ AlertDialogDescription,
43
+ AlertDialogFooter,
44
+ AlertDialogHeader,
45
+ AlertDialogTitle,
46
+ AlertDialogTrigger,
47
+ } from '@nextsparkjs/core/components/ui/alert-dialog'
48
+
49
+ interface Product {
50
+ id: string
51
+ code: string
52
+ name: string
53
+ category?: string
54
+ type?: 'product' | 'service' | 'subscription' | 'bundle' | 'addon'
55
+ description?: string
56
+ price: number
57
+ cost?: number
58
+ currency?: string
59
+ unit?: string
60
+ isActive?: boolean
61
+ minimumQuantity?: number
62
+ image?: string
63
+ brochureUrl?: string
64
+ commissionRate?: number
65
+ createdAt: string
66
+ updatedAt?: string
67
+ }
68
+
69
+ // Type badge component
70
+ function TypeBadge({ type }: { type?: Product['type'] }) {
71
+ const config = {
72
+ product: { label: 'Product', className: 'bg-primary/10 text-primary' },
73
+ service: { label: 'Service', className: 'bg-violet-500/10 text-violet-600' },
74
+ subscription: { label: 'Subscription', className: 'bg-amber-500/10 text-amber-600' },
75
+ bundle: { label: 'Bundle', className: 'bg-emerald-500/10 text-emerald-600' },
76
+ addon: { label: 'Add-on', className: 'bg-sky-500/10 text-sky-600' },
77
+ }
78
+
79
+ const { label, className } = config[type || 'product'] || config.product
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
+ // Status badge component
89
+ function StatusBadge({ isActive }: { isActive?: boolean }) {
90
+ return isActive ? (
91
+ <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium bg-emerald-500/10 text-emerald-600">
92
+ <CheckCircle className="w-3 h-3" />
93
+ Active
94
+ </span>
95
+ ) : (
96
+ <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium bg-muted text-muted-foreground">
97
+ <XCircle className="w-3 h-3" />
98
+ Inactive
99
+ </span>
100
+ )
101
+ }
102
+
103
+ // Metric card component
104
+ function MetricCard({
105
+ icon: Icon,
106
+ label,
107
+ value,
108
+ subValue,
109
+ iconColor = 'text-primary',
110
+ iconBg = 'bg-primary/10'
111
+ }: {
112
+ icon: React.ElementType
113
+ label: string
114
+ value: string
115
+ subValue?: string
116
+ iconColor?: string
117
+ iconBg?: string
118
+ }) {
119
+ return (
120
+ <div className="bg-card border rounded-xl p-4">
121
+ <div className="flex items-center gap-3">
122
+ <div className={cn('w-10 h-10 rounded-lg flex items-center justify-center', iconBg)}>
123
+ <Icon className={cn('w-5 h-5', iconColor)} />
124
+ </div>
125
+ <div>
126
+ <p className="text-2xl font-bold text-foreground">{value}</p>
127
+ <p className="text-xs text-muted-foreground">{label}</p>
128
+ {subValue && (
129
+ <p className="text-xs text-muted-foreground mt-0.5">{subValue}</p>
130
+ )}
131
+ </div>
132
+ </div>
133
+ </div>
134
+ )
135
+ }
136
+
137
+ // Detail item component
138
+ function DetailItem({
139
+ icon: Icon,
140
+ label,
141
+ value
142
+ }: {
143
+ icon: React.ElementType
144
+ label: string
145
+ value: React.ReactNode
146
+ }) {
147
+ return (
148
+ <div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
149
+ <Icon className="w-4 h-4 text-muted-foreground mt-0.5" />
150
+ <div className="flex-1 min-w-0">
151
+ <p className="text-xs text-muted-foreground">{label}</p>
152
+ <p className="text-sm font-medium text-foreground mt-0.5">{value || '-'}</p>
153
+ </div>
154
+ </div>
155
+ )
156
+ }
157
+
158
+ export default function ProductDetailPage() {
159
+ const router = useRouter()
160
+ const params = useParams()
161
+ const productId = params.id as string
162
+
163
+ const { currentTeam, isLoading: teamLoading } = useTeamContext()
164
+ const [product, setProduct] = useState<Product | null>(null)
165
+ const [isLoading, setIsLoading] = useState(true)
166
+ const [isDeleting, setIsDeleting] = useState(false)
167
+
168
+ // Permission checks
169
+ const canUpdate = usePermission('products.update')
170
+ const canDelete = usePermission('products.delete')
171
+
172
+ useEffect(() => {
173
+ if (teamLoading || !currentTeam || !productId) return
174
+
175
+ async function fetchProduct() {
176
+ try {
177
+ const response = await fetchWithTeam(`/api/v1/products/${productId}`)
178
+ if (!response.ok) throw new Error('Failed to fetch product')
179
+ const result = await response.json()
180
+ setProduct(result.data || result)
181
+ } catch (error) {
182
+ console.error('Error loading product:', error)
183
+ } finally {
184
+ setIsLoading(false)
185
+ }
186
+ }
187
+
188
+ fetchProduct()
189
+ }, [teamLoading, currentTeam, productId])
190
+
191
+ // Calculate margin
192
+ const margin = useMemo(() => {
193
+ if (!product?.price || !product?.cost || product.cost === 0) return null
194
+ return ((product.price - product.cost) / product.price) * 100
195
+ }, [product])
196
+
197
+ // Currency formatter
198
+ const formatCurrency = (value: number, currency: string = 'USD') => {
199
+ return new Intl.NumberFormat('en-US', {
200
+ style: 'currency',
201
+ currency: currency || 'USD', // Handle null case
202
+ minimumFractionDigits: 2,
203
+ maximumFractionDigits: 2,
204
+ }).format(value)
205
+ }
206
+
207
+ // Unit label mapper
208
+ const getUnitLabel = (unit?: string) => {
209
+ const units: Record<string, string> = {
210
+ piece: 'Piece',
211
+ hour: 'Hour',
212
+ day: 'Day',
213
+ week: 'Week',
214
+ month: 'Month',
215
+ year: 'Year',
216
+ kg: 'Kilogram',
217
+ lb: 'Pound',
218
+ meter: 'Meter',
219
+ foot: 'Foot',
220
+ license: 'License',
221
+ user: 'User',
222
+ }
223
+ return units[unit || ''] || unit || '-'
224
+ }
225
+
226
+ // Handle delete
227
+ const handleDelete = async () => {
228
+ if (!product) return
229
+
230
+ setIsDeleting(true)
231
+ try {
232
+ const response = await fetchWithTeam(`/api/v1/products/${product.id}`, {
233
+ method: 'DELETE',
234
+ })
235
+
236
+ if (!response.ok) throw new Error('Failed to delete product')
237
+
238
+ router.push('/dashboard/products')
239
+ } catch (error) {
240
+ console.error('Error deleting product:', error)
241
+ setIsDeleting(false)
242
+ }
243
+ }
244
+
245
+ // Loading state
246
+ if (isLoading) {
247
+ return (
248
+ <div className="flex items-center justify-center min-h-[400px]">
249
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
250
+ </div>
251
+ )
252
+ }
253
+
254
+ // Not found state
255
+ if (!product) {
256
+ return (
257
+ <div className="flex flex-col items-center justify-center min-h-[400px] gap-4 p-6">
258
+ <Package className="w-12 h-12 text-muted-foreground" />
259
+ <div className="text-center">
260
+ <h2 className="text-xl font-semibold">Product Not Found</h2>
261
+ <p className="text-sm text-muted-foreground mt-1">
262
+ The product you&apos;re looking for doesn&apos;t exist or has been deleted.
263
+ </p>
264
+ </div>
265
+ <Button variant="outline" onClick={() => router.push('/dashboard/products')}>
266
+ <ArrowLeft className="w-4 h-4 mr-2" />
267
+ Back to Products
268
+ </Button>
269
+ </div>
270
+ )
271
+ }
272
+
273
+ return (
274
+ <div className="p-6 space-y-6">
275
+ {/* Header */}
276
+ <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
277
+ <div className="flex items-start gap-4">
278
+ <Button
279
+ variant="ghost"
280
+ size="icon"
281
+ onClick={() => router.push('/dashboard/products')}
282
+ className="shrink-0"
283
+ >
284
+ <ArrowLeft className="w-4 h-4" />
285
+ </Button>
286
+ <div>
287
+ <div className="flex items-center gap-3">
288
+ <h1 className="text-2xl font-bold text-foreground tracking-tight">
289
+ {product.name}
290
+ </h1>
291
+ <StatusBadge isActive={product.isActive} />
292
+ </div>
293
+ <div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
294
+ <span className="flex items-center gap-1">
295
+ <Hash className="w-3.5 h-3.5" />
296
+ {product.code}
297
+ </span>
298
+ {product.category && (
299
+ <>
300
+ <span>•</span>
301
+ <span className="flex items-center gap-1">
302
+ <Tag className="w-3.5 h-3.5" />
303
+ {product.category}
304
+ </span>
305
+ </>
306
+ )}
307
+ <span>•</span>
308
+ <TypeBadge type={product.type} />
309
+ </div>
310
+ </div>
311
+ </div>
312
+
313
+ {/* Action buttons - Only for owner */}
314
+ <div className="flex items-center gap-2 sm:shrink-0">
315
+ {canUpdate && (
316
+ <Button
317
+ variant="outline"
318
+ onClick={() => router.push(`/dashboard/products/${product.id}/edit`)}
319
+ className="gap-2"
320
+ >
321
+ <Edit className="w-4 h-4" />
322
+ Edit
323
+ </Button>
324
+ )}
325
+ {canDelete && (
326
+ <AlertDialog>
327
+ <AlertDialogTrigger asChild>
328
+ <Button variant="destructive" className="gap-2">
329
+ <Trash2 className="w-4 h-4" />
330
+ Delete
331
+ </Button>
332
+ </AlertDialogTrigger>
333
+ <AlertDialogContent>
334
+ <AlertDialogHeader>
335
+ <AlertDialogTitle>Delete Product</AlertDialogTitle>
336
+ <AlertDialogDescription>
337
+ Are you sure you want to delete &quot;{product.name}&quot;? This action cannot be undone.
338
+ </AlertDialogDescription>
339
+ </AlertDialogHeader>
340
+ <AlertDialogFooter>
341
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
342
+ <AlertDialogAction
343
+ onClick={handleDelete}
344
+ disabled={isDeleting}
345
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
346
+ >
347
+ {isDeleting ? 'Deleting...' : 'Delete'}
348
+ </AlertDialogAction>
349
+ </AlertDialogFooter>
350
+ </AlertDialogContent>
351
+ </AlertDialog>
352
+ )}
353
+ </div>
354
+ </div>
355
+
356
+ {/* Metrics */}
357
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
358
+ <MetricCard
359
+ icon={DollarSign}
360
+ label="Price"
361
+ value={formatCurrency(product.price, product.currency)}
362
+ subValue={product.unit ? `per ${getUnitLabel(product.unit)}` : undefined}
363
+ iconColor="text-primary"
364
+ iconBg="bg-primary/10"
365
+ />
366
+ <MetricCard
367
+ icon={Calculator}
368
+ label="Cost"
369
+ value={product.cost ? formatCurrency(product.cost, product.currency) : '-'}
370
+ iconColor="text-amber-600"
371
+ iconBg="bg-amber-500/10"
372
+ />
373
+ <MetricCard
374
+ icon={Percent}
375
+ label="Margin"
376
+ value={margin !== null ? `${margin.toFixed(1)}%` : '-'}
377
+ iconColor={margin && margin >= 50 ? 'text-emerald-600' : margin && margin >= 30 ? 'text-amber-600' : 'text-muted-foreground'}
378
+ iconBg={margin && margin >= 50 ? 'bg-emerald-500/10' : margin && margin >= 30 ? 'bg-amber-500/10' : 'bg-muted'}
379
+ />
380
+ <MetricCard
381
+ icon={Users}
382
+ label="Commission"
383
+ value={product.commissionRate ? `${product.commissionRate}%` : '-'}
384
+ iconColor="text-violet-600"
385
+ iconBg="bg-violet-500/10"
386
+ />
387
+ </div>
388
+
389
+ {/* Product Details */}
390
+ <Card>
391
+ <CardHeader>
392
+ <CardTitle className="text-base font-semibold flex items-center gap-2">
393
+ <Layers className="w-4 h-4" />
394
+ Product Details
395
+ </CardTitle>
396
+ </CardHeader>
397
+ <CardContent>
398
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
399
+ <DetailItem
400
+ icon={Tag}
401
+ label="Type"
402
+ value={<TypeBadge type={product.type} />}
403
+ />
404
+ <DetailItem
405
+ icon={DollarSign}
406
+ label="Currency"
407
+ value={product.currency || 'USD'}
408
+ />
409
+ <DetailItem
410
+ icon={Scale}
411
+ label="Unit of Measure"
412
+ value={getUnitLabel(product.unit)}
413
+ />
414
+ <DetailItem
415
+ icon={Hash}
416
+ label="Minimum Quantity"
417
+ value={product.minimumQuantity?.toString() || '1'}
418
+ />
419
+ <DetailItem
420
+ icon={CheckCircle}
421
+ label="Status"
422
+ value={<StatusBadge isActive={product.isActive} />}
423
+ />
424
+ </div>
425
+ </CardContent>
426
+ </Card>
427
+
428
+ {/* Description */}
429
+ {product.description && (
430
+ <Card>
431
+ <CardHeader>
432
+ <CardTitle className="text-base font-semibold flex items-center gap-2">
433
+ <FileText className="w-4 h-4" />
434
+ Description
435
+ </CardTitle>
436
+ </CardHeader>
437
+ <CardContent>
438
+ <p className="text-sm text-muted-foreground whitespace-pre-wrap">
439
+ {product.description}
440
+ </p>
441
+ </CardContent>
442
+ </Card>
443
+ )}
444
+
445
+
446
+ {/* Resources */}
447
+ {(product.image || product.brochureUrl) && (
448
+ <Card>
449
+ <CardHeader>
450
+ <CardTitle className="text-base font-semibold flex items-center gap-2">
451
+ <ExternalLink className="w-4 h-4" />
452
+ Resources
453
+ </CardTitle>
454
+ </CardHeader>
455
+ <CardContent>
456
+ <div className="flex flex-wrap gap-4">
457
+ {product.image && (
458
+ <a
459
+ href={product.image}
460
+ target="_blank"
461
+ rel="noopener noreferrer"
462
+ className="flex items-center gap-2 px-4 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors text-sm"
463
+ >
464
+ <ImageIcon className="w-4 h-4 text-muted-foreground" />
465
+ View Image
466
+ <ExternalLink className="w-3 h-3 text-muted-foreground" />
467
+ </a>
468
+ )}
469
+ {product.brochureUrl && (
470
+ <a
471
+ href={product.brochureUrl}
472
+ target="_blank"
473
+ rel="noopener noreferrer"
474
+ className="flex items-center gap-2 px-4 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors text-sm"
475
+ >
476
+ <FileText className="w-4 h-4 text-muted-foreground" />
477
+ Download Brochure
478
+ <ExternalLink className="w-3 h-3 text-muted-foreground" />
479
+ </a>
480
+ )}
481
+ </div>
482
+ </CardContent>
483
+ </Card>
484
+ )}
485
+
486
+ {/* Timestamps */}
487
+ <div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground pt-4 border-t">
488
+ <div className="flex items-center gap-1.5">
489
+ <Calendar className="w-3.5 h-3.5" />
490
+ Created: {new Date(product.createdAt).toLocaleDateString('en-US', {
491
+ year: 'numeric',
492
+ month: 'short',
493
+ day: 'numeric',
494
+ })}
495
+ </div>
496
+ {product.updatedAt && (
497
+ <div className="flex items-center gap-1.5">
498
+ <Clock className="w-3.5 h-3.5" />
499
+ Updated: {new Date(product.updatedAt).toLocaleDateString('en-US', {
500
+ year: 'numeric',
501
+ month: 'short',
502
+ day: 'numeric',
503
+ })}
504
+ </div>
505
+ )}
506
+ </div>
507
+ </div>
508
+ )
509
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Product Create Page
3
+ * Form for creating new products - Owner only
4
+ */
5
+
6
+ 'use client'
7
+
8
+ import { useRouter } from 'next/navigation'
9
+ import { useState, useEffect } from 'react'
10
+ import { Button } from '@nextsparkjs/core/components/ui/button'
11
+ import { EntityFormWrapper } from '@nextsparkjs/core/components/entities/wrappers/EntityFormWrapper'
12
+ import { useTeamContext } from '@nextsparkjs/core/contexts/TeamContext'
13
+ import { usePermission } from '@nextsparkjs/core/lib/permissions/hooks'
14
+ import { ShieldAlert, ArrowLeft } from 'lucide-react'
15
+
16
+ // Access Denied component for when user doesn't have permission
17
+ function AccessDeniedView({
18
+ title = 'Access Denied',
19
+ message = "You don't have permission to perform this action",
20
+ backUrl = '/dashboard/products'
21
+ }: {
22
+ title?: string
23
+ message?: string
24
+ backUrl?: string
25
+ }) {
26
+ const router = useRouter()
27
+
28
+ return (
29
+ <div className="flex flex-col items-center justify-center min-h-[400px] gap-4 p-6">
30
+ <div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center">
31
+ <ShieldAlert className="w-8 h-8 text-destructive" />
32
+ </div>
33
+ <div className="text-center space-y-2">
34
+ <h2 className="text-xl font-semibold">{title}</h2>
35
+ <p className="text-sm text-muted-foreground max-w-md">{message}</p>
36
+ </div>
37
+ <Button variant="outline" onClick={() => router.push(backUrl)}>
38
+ <ArrowLeft className="w-4 h-4 mr-2" />
39
+ Back to Products
40
+ </Button>
41
+ </div>
42
+ )
43
+ }
44
+
45
+ export default function ProductCreatePage() {
46
+ const router = useRouter()
47
+ const { currentTeam, isLoading: teamLoading } = useTeamContext()
48
+ const [permissionChecked, setPermissionChecked] = useState(false)
49
+
50
+ // Permission check - only owner can create products
51
+ const canCreate = usePermission('products.create')
52
+
53
+ // Wait for team context to load before checking permissions
54
+ useEffect(() => {
55
+ if (!teamLoading && currentTeam) {
56
+ setPermissionChecked(true)
57
+ }
58
+ }, [teamLoading, currentTeam])
59
+
60
+ // Loading state while checking permissions
61
+ if (!permissionChecked) {
62
+ return (
63
+ <div className="flex items-center justify-center min-h-[400px]">
64
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
65
+ </div>
66
+ )
67
+ }
68
+
69
+ // Permission denied - show access denied page
70
+ if (!canCreate) {
71
+ return (
72
+ <AccessDeniedView
73
+ title="Cannot Create Product"
74
+ message="Only the team owner can create new products in the catalog. Please contact your team owner if you need to add a product."
75
+ />
76
+ )
77
+ }
78
+
79
+ // Has permission - show the form
80
+ return (
81
+ <EntityFormWrapper
82
+ entityType="products"
83
+ mode="create"
84
+ onSuccess={(createdId) => {
85
+ if (createdId) {
86
+ router.push(`/dashboard/products/${createdId}`)
87
+ } else {
88
+ router.push('/dashboard/products')
89
+ }
90
+ }}
91
+ onError={(error) => {
92
+ console.error('Error creating product:', error)
93
+ }}
94
+ />
95
+ )
96
+ }