@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,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're looking for doesn'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 "{product.name}"? 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
|
+
}
|