@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,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRM TopBar Component
|
|
3
|
+
* Professional top navigation bar for CRM dashboard
|
|
4
|
+
*
|
|
5
|
+
* Uses useQuickCreateEntities hook to dynamically show quick create options
|
|
6
|
+
* based on user permissions and entity configuration.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use client'
|
|
10
|
+
|
|
11
|
+
import Link from 'next/link'
|
|
12
|
+
import Image from 'next/image'
|
|
13
|
+
import { useAuth } from '@nextsparkjs/core/hooks/useAuth'
|
|
14
|
+
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
15
|
+
import {
|
|
16
|
+
DropdownMenu,
|
|
17
|
+
DropdownMenuContent,
|
|
18
|
+
DropdownMenuItem,
|
|
19
|
+
DropdownMenuLabel,
|
|
20
|
+
DropdownMenuSeparator,
|
|
21
|
+
DropdownMenuTrigger,
|
|
22
|
+
} from '@nextsparkjs/core/components/ui/dropdown-menu'
|
|
23
|
+
import {
|
|
24
|
+
Bell,
|
|
25
|
+
Search,
|
|
26
|
+
Plus,
|
|
27
|
+
User,
|
|
28
|
+
Settings,
|
|
29
|
+
CreditCard,
|
|
30
|
+
LogOut,
|
|
31
|
+
Sun,
|
|
32
|
+
Moon,
|
|
33
|
+
HelpCircle,
|
|
34
|
+
ChevronDown,
|
|
35
|
+
Loader2
|
|
36
|
+
} from 'lucide-react'
|
|
37
|
+
import { useState, useCallback, useMemo } from 'react'
|
|
38
|
+
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
39
|
+
import { useTheme } from 'next-themes'
|
|
40
|
+
import { useQuickCreateEntities } from '@nextsparkjs/core/hooks/useQuickCreateEntities'
|
|
41
|
+
|
|
42
|
+
export function CRMTopBar() {
|
|
43
|
+
const { user, signOut, isLoading } = useAuth()
|
|
44
|
+
const { theme, setTheme } = useTheme()
|
|
45
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
46
|
+
const [showSearch, setShowSearch] = useState(false)
|
|
47
|
+
|
|
48
|
+
// Get quick create entities filtered by permissions
|
|
49
|
+
const { entities: quickCreateEntities, isLoading: isLoadingEntities, hasEntities } = useQuickCreateEntities()
|
|
50
|
+
|
|
51
|
+
// Transform entities to quick actions format
|
|
52
|
+
const quickActions = useMemo(() => {
|
|
53
|
+
return quickCreateEntities.map(entity => ({
|
|
54
|
+
label: `New ${entity.names?.singular || entity.slug}`,
|
|
55
|
+
href: `/dashboard/${entity.slug}/create`,
|
|
56
|
+
icon: entity.icon
|
|
57
|
+
}))
|
|
58
|
+
}, [quickCreateEntities])
|
|
59
|
+
|
|
60
|
+
// Generate user initials
|
|
61
|
+
const getUserInitials = (user: { firstName?: string; lastName?: string; email: string }) => {
|
|
62
|
+
if (user.firstName && user.lastName) {
|
|
63
|
+
return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase()
|
|
64
|
+
}
|
|
65
|
+
if (user.firstName) {
|
|
66
|
+
return user.firstName.slice(0, 2).toUpperCase()
|
|
67
|
+
}
|
|
68
|
+
return user.email.slice(0, 2).toUpperCase()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const handleSignOut = useCallback(async () => {
|
|
72
|
+
try {
|
|
73
|
+
await signOut()
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error('Sign out failed:', error)
|
|
76
|
+
}
|
|
77
|
+
}, [signOut])
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<header
|
|
81
|
+
data-cy="crm-topbar"
|
|
82
|
+
className={cn(
|
|
83
|
+
'hidden lg:block bg-background/80 backdrop-blur-md border-b border-border/50 fixed top-0 right-0 z-40 transition-all duration-300 ease-out'
|
|
84
|
+
)}
|
|
85
|
+
style={{ left: 'var(--crm-sidebar-width, 4rem)' }}
|
|
86
|
+
>
|
|
87
|
+
<div className="h-16 px-6 flex items-center justify-between gap-4">
|
|
88
|
+
{/* Center: Search */}
|
|
89
|
+
<div className="hidden md:flex flex-1 max-w-lg">
|
|
90
|
+
<div className="relative w-full">
|
|
91
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
92
|
+
<input
|
|
93
|
+
type="text"
|
|
94
|
+
placeholder="Search leads, contacts, deals..."
|
|
95
|
+
value={searchQuery}
|
|
96
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
97
|
+
className="w-full pl-10 pr-4 py-2 bg-muted/50 border border-border rounded-lg text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
|
98
|
+
data-cy="crm-topbar-search-input"
|
|
99
|
+
/>
|
|
100
|
+
{searchQuery && (
|
|
101
|
+
<kbd className="absolute right-3 top-1/2 -translate-y-1/2 hidden sm:inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
|
102
|
+
ESC
|
|
103
|
+
</kbd>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{/* Right side: Actions */}
|
|
109
|
+
<div className="flex items-center gap-2">
|
|
110
|
+
{/* Mobile search toggle */}
|
|
111
|
+
<Button
|
|
112
|
+
variant="ghost"
|
|
113
|
+
size="icon"
|
|
114
|
+
className="md:hidden h-9 w-9"
|
|
115
|
+
onClick={() => setShowSearch(!showSearch)}
|
|
116
|
+
data-cy="crm-topbar-search-mobile-toggle"
|
|
117
|
+
>
|
|
118
|
+
<Search className="h-4 w-4" />
|
|
119
|
+
</Button>
|
|
120
|
+
|
|
121
|
+
{/* Quick Create - Permission-filtered */}
|
|
122
|
+
{isLoadingEntities ? (
|
|
123
|
+
<Button size="sm" className="gap-2 h-9" disabled>
|
|
124
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
125
|
+
<span className="hidden sm:inline">New</span>
|
|
126
|
+
</Button>
|
|
127
|
+
) : hasEntities ? (
|
|
128
|
+
<DropdownMenu>
|
|
129
|
+
<DropdownMenuTrigger asChild>
|
|
130
|
+
<Button size="sm" className="gap-2 h-9" data-cy="crm-topbar-quick-create-btn">
|
|
131
|
+
<Plus className="h-4 w-4" />
|
|
132
|
+
<span className="hidden sm:inline">New</span>
|
|
133
|
+
<ChevronDown className="h-3 w-3 opacity-50" />
|
|
134
|
+
</Button>
|
|
135
|
+
</DropdownMenuTrigger>
|
|
136
|
+
<DropdownMenuContent align="end" className="w-48" data-cy="crm-topbar-quick-create-dropdown">
|
|
137
|
+
{quickActions.map((action) => (
|
|
138
|
+
<DropdownMenuItem key={action.href} asChild>
|
|
139
|
+
<Link href={action.href} className="flex items-center gap-2">
|
|
140
|
+
{action.icon && <action.icon className="h-4 w-4" />}
|
|
141
|
+
{action.label}
|
|
142
|
+
</Link>
|
|
143
|
+
</DropdownMenuItem>
|
|
144
|
+
))}
|
|
145
|
+
</DropdownMenuContent>
|
|
146
|
+
</DropdownMenu>
|
|
147
|
+
) : null}
|
|
148
|
+
|
|
149
|
+
{/* Notifications */}
|
|
150
|
+
<Button variant="ghost" size="icon" className="h-9 w-9 relative" data-cy="crm-topbar-notifications-btn">
|
|
151
|
+
<Bell className="h-4 w-4" />
|
|
152
|
+
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-destructive rounded-full" />
|
|
153
|
+
</Button>
|
|
154
|
+
|
|
155
|
+
{/* Help */}
|
|
156
|
+
<Button variant="ghost" size="icon" className="h-9 w-9 hidden sm:flex" data-cy="crm-topbar-help-btn">
|
|
157
|
+
<HelpCircle className="h-4 w-4" />
|
|
158
|
+
</Button>
|
|
159
|
+
|
|
160
|
+
{/* Theme Toggle */}
|
|
161
|
+
<Button
|
|
162
|
+
variant="ghost"
|
|
163
|
+
size="icon"
|
|
164
|
+
className="h-9 w-9"
|
|
165
|
+
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
|
166
|
+
data-cy="crm-topbar-theme-toggle"
|
|
167
|
+
>
|
|
168
|
+
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
|
169
|
+
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
|
170
|
+
</Button>
|
|
171
|
+
|
|
172
|
+
{/* User Menu */}
|
|
173
|
+
{isLoading ? (
|
|
174
|
+
<div className="h-9 w-9 bg-muted animate-pulse rounded-full" />
|
|
175
|
+
) : user ? (
|
|
176
|
+
<DropdownMenu>
|
|
177
|
+
<DropdownMenuTrigger asChild>
|
|
178
|
+
<Button variant="ghost" className="h-9 gap-2 pl-2 pr-3" data-cy="crm-topbar-user-menu-trigger">
|
|
179
|
+
{user.image ? (
|
|
180
|
+
<Image
|
|
181
|
+
src={user.image}
|
|
182
|
+
alt=""
|
|
183
|
+
width={28}
|
|
184
|
+
height={28}
|
|
185
|
+
className="h-7 w-7 rounded-full object-cover"
|
|
186
|
+
/>
|
|
187
|
+
) : (
|
|
188
|
+
<div className="h-7 w-7 rounded-full bg-primary flex items-center justify-center text-xs font-medium text-primary-foreground">
|
|
189
|
+
{getUserInitials(user)}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
<ChevronDown className="h-3 w-3 opacity-50 hidden sm:block" />
|
|
193
|
+
</Button>
|
|
194
|
+
</DropdownMenuTrigger>
|
|
195
|
+
<DropdownMenuContent align="end" className="w-56" data-cy="crm-topbar-user-menu-content">
|
|
196
|
+
<DropdownMenuLabel>
|
|
197
|
+
<div className="flex flex-col space-y-1">
|
|
198
|
+
<p className="text-sm font-medium">{user.firstName || 'User'}</p>
|
|
199
|
+
<p className="text-xs text-muted-foreground">{user.email}</p>
|
|
200
|
+
</div>
|
|
201
|
+
</DropdownMenuLabel>
|
|
202
|
+
<DropdownMenuSeparator />
|
|
203
|
+
<DropdownMenuItem asChild data-cy="crm-topbar-menu-profile">
|
|
204
|
+
<Link href="/dashboard/settings/profile" className="flex items-center">
|
|
205
|
+
<User className="mr-2 h-4 w-4" />
|
|
206
|
+
Profile
|
|
207
|
+
</Link>
|
|
208
|
+
</DropdownMenuItem>
|
|
209
|
+
<DropdownMenuItem asChild data-cy="crm-topbar-menu-settings">
|
|
210
|
+
<Link href="/dashboard/settings" className="flex items-center">
|
|
211
|
+
<Settings className="mr-2 h-4 w-4" />
|
|
212
|
+
Settings
|
|
213
|
+
</Link>
|
|
214
|
+
</DropdownMenuItem>
|
|
215
|
+
<DropdownMenuItem asChild data-cy="crm-topbar-menu-billing">
|
|
216
|
+
<Link href="/dashboard/settings/billing" className="flex items-center">
|
|
217
|
+
<CreditCard className="mr-2 h-4 w-4" />
|
|
218
|
+
Billing
|
|
219
|
+
</Link>
|
|
220
|
+
</DropdownMenuItem>
|
|
221
|
+
<DropdownMenuSeparator />
|
|
222
|
+
<DropdownMenuItem
|
|
223
|
+
onClick={handleSignOut}
|
|
224
|
+
className="text-destructive focus:text-destructive"
|
|
225
|
+
data-cy="crm-topbar-menu-signout"
|
|
226
|
+
>
|
|
227
|
+
<LogOut className="mr-2 h-4 w-4" />
|
|
228
|
+
Sign out
|
|
229
|
+
</DropdownMenuItem>
|
|
230
|
+
</DropdownMenuContent>
|
|
231
|
+
</DropdownMenu>
|
|
232
|
+
) : (
|
|
233
|
+
<div className="flex items-center gap-2">
|
|
234
|
+
<Button variant="ghost" size="sm" asChild>
|
|
235
|
+
<Link href="/login">Sign in</Link>
|
|
236
|
+
</Button>
|
|
237
|
+
<Button size="sm" asChild>
|
|
238
|
+
<Link href="/signup">Sign up</Link>
|
|
239
|
+
</Button>
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
{/* Mobile search bar */}
|
|
246
|
+
{showSearch && (
|
|
247
|
+
<div className="md:hidden px-4 pb-4 animate-in slide-in-from-top-2">
|
|
248
|
+
<div className="relative">
|
|
249
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
250
|
+
<input
|
|
251
|
+
type="text"
|
|
252
|
+
placeholder="Search..."
|
|
253
|
+
value={searchQuery}
|
|
254
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
255
|
+
className="w-full pl-10 pr-4 py-2 bg-muted/50 border border-border rounded-lg text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
|
256
|
+
autoFocus
|
|
257
|
+
/>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
</header>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export default CRMTopBar
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deal Card Component
|
|
3
|
+
* Professional card for opportunities in the Kanban board
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client'
|
|
7
|
+
|
|
8
|
+
import React from 'react'
|
|
9
|
+
import { formatCurrency, formatRelativeDate, isDealRotten } from '@/themes/crm/lib/crm-utils'
|
|
10
|
+
import { Building2, Clock, AlertTriangle, TrendingUp } from 'lucide-react'
|
|
11
|
+
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
12
|
+
|
|
13
|
+
export interface Deal {
|
|
14
|
+
id: string
|
|
15
|
+
name: string
|
|
16
|
+
companyId: string
|
|
17
|
+
companyName?: string
|
|
18
|
+
amount: number
|
|
19
|
+
currency?: string
|
|
20
|
+
probability: number
|
|
21
|
+
assignedTo?: string
|
|
22
|
+
assignedToName?: string
|
|
23
|
+
updatedAt: string | Date
|
|
24
|
+
stageId: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface DealCardProps {
|
|
28
|
+
deal: Deal
|
|
29
|
+
onClick?: () => void
|
|
30
|
+
isDragging?: boolean
|
|
31
|
+
rottenDays?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Probability color based on percentage
|
|
35
|
+
const getProbabilityStyle = (probability: number) => {
|
|
36
|
+
if (probability >= 75) return { bg: 'bg-emerald-500/10', text: 'text-emerald-600', fill: 'bg-emerald-500' }
|
|
37
|
+
if (probability >= 50) return { bg: 'bg-amber-500/10', text: 'text-amber-600', fill: 'bg-amber-500' }
|
|
38
|
+
if (probability >= 25) return { bg: 'bg-orange-500/10', text: 'text-orange-600', fill: 'bg-orange-500' }
|
|
39
|
+
return { bg: 'bg-muted', text: 'text-muted-foreground', fill: 'bg-muted-foreground' }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function DealCard({ deal, onClick, isDragging, rottenDays = 30 }: DealCardProps) {
|
|
43
|
+
const isRotten = isDealRotten(deal.updatedAt, rottenDays)
|
|
44
|
+
const probStyle = getProbabilityStyle(deal.probability)
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
className={cn(
|
|
49
|
+
'bg-card border rounded-xl p-4 transition-all duration-200 cursor-grab active:cursor-grabbing',
|
|
50
|
+
'hover:shadow-md hover:border-primary/30',
|
|
51
|
+
isDragging && 'opacity-50 shadow-lg scale-105 rotate-2',
|
|
52
|
+
isRotten && 'border-l-4 border-l-destructive'
|
|
53
|
+
)}
|
|
54
|
+
onClick={onClick}
|
|
55
|
+
role="button"
|
|
56
|
+
tabIndex={0}
|
|
57
|
+
data-cy={`deal-card-${deal.id}`}
|
|
58
|
+
>
|
|
59
|
+
{/* Header */}
|
|
60
|
+
<div className="mb-3">
|
|
61
|
+
<h4 className="font-semibold text-sm text-foreground leading-tight mb-1 line-clamp-2" data-cy={`deal-card-name-${deal.id}`}>
|
|
62
|
+
{deal.name}
|
|
63
|
+
</h4>
|
|
64
|
+
|
|
65
|
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
66
|
+
<Building2 className="w-3 h-3" />
|
|
67
|
+
<span className="truncate" data-cy={`deal-card-company-${deal.id}`}>{deal.companyName || 'No company'}</span>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
{/* Amount and Probability */}
|
|
72
|
+
<div className="flex items-end justify-between mb-3">
|
|
73
|
+
<div className="font-bold text-lg text-primary" data-cy={`deal-card-amount-${deal.id}`}>
|
|
74
|
+
{formatCurrency(deal.amount, deal.currency || 'USD')}
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div className={cn('flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium', probStyle.bg, probStyle.text)}>
|
|
78
|
+
<TrendingUp className="w-3 h-3" />
|
|
79
|
+
{deal.probability}%
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Probability bar */}
|
|
84
|
+
<div className="h-1.5 bg-muted rounded-full overflow-hidden mb-3">
|
|
85
|
+
<div
|
|
86
|
+
className={cn('h-full rounded-full transition-all duration-300', probStyle.fill)}
|
|
87
|
+
style={{ width: `${deal.probability}%` }}
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{/* Footer */}
|
|
92
|
+
<div className="flex items-center justify-between">
|
|
93
|
+
{deal.assignedToName ? (
|
|
94
|
+
<div className="flex items-center gap-1.5">
|
|
95
|
+
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-[10px] font-semibold text-primary">
|
|
96
|
+
{deal.assignedToName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()}
|
|
97
|
+
</div>
|
|
98
|
+
<span className="text-xs text-muted-foreground truncate max-w-[80px]">
|
|
99
|
+
{deal.assignedToName.split(' ')[0]}
|
|
100
|
+
</span>
|
|
101
|
+
</div>
|
|
102
|
+
) : (
|
|
103
|
+
<span className="text-xs text-muted-foreground">Unassigned</span>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
107
|
+
<Clock className="w-3 h-3" />
|
|
108
|
+
<span>{formatRelativeDate(deal.updatedAt)}</span>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{/* Rotten warning */}
|
|
113
|
+
{isRotten && (
|
|
114
|
+
<div className="mt-3 pt-3 border-t border-destructive/20 flex items-center gap-1.5 text-xs text-destructive font-medium">
|
|
115
|
+
<AlertTriangle className="w-3.5 h-3.5" />
|
|
116
|
+
<span>Needs attention - no activity for {rottenDays}+ days</span>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export default DealCard
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity Card Component
|
|
3
|
+
* Reusable card for displaying entity summaries
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from 'react'
|
|
7
|
+
import '@/contents/themes/crm/styles/crm-theme.css'
|
|
8
|
+
|
|
9
|
+
interface EntityCardProps {
|
|
10
|
+
title: string
|
|
11
|
+
subtitle?: string
|
|
12
|
+
meta?: string
|
|
13
|
+
status?: {
|
|
14
|
+
label: string
|
|
15
|
+
variant: 'success' | 'warning' | 'danger' | 'info' | 'neutral'
|
|
16
|
+
}
|
|
17
|
+
onClick?: () => void
|
|
18
|
+
children?: React.ReactNode
|
|
19
|
+
className?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function EntityCard({
|
|
23
|
+
title,
|
|
24
|
+
subtitle,
|
|
25
|
+
meta,
|
|
26
|
+
status,
|
|
27
|
+
onClick,
|
|
28
|
+
children,
|
|
29
|
+
className = '',
|
|
30
|
+
}: EntityCardProps) {
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
className={`crm-entity-card ${className}`}
|
|
34
|
+
onClick={onClick}
|
|
35
|
+
role={onClick ? 'button' : undefined}
|
|
36
|
+
tabIndex={onClick ? 0 : undefined}
|
|
37
|
+
>
|
|
38
|
+
<div className="crm-entity-card-header">
|
|
39
|
+
<div>
|
|
40
|
+
<div className="crm-entity-card-title">{title}</div>
|
|
41
|
+
{subtitle && <div className="crm-entity-card-meta">{subtitle}</div>}
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
{status && (
|
|
45
|
+
<span className={`crm-badge ${status.variant}`}>
|
|
46
|
+
{status.label}
|
|
47
|
+
</span>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
{meta && <div className="crm-entity-card-meta">{meta}</div>}
|
|
52
|
+
|
|
53
|
+
{children && <div className="mt-3">{children}</div>}
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default EntityCard
|