@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,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activity Card Component
|
|
3
|
+
* Professional card design for activities with modern styling
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client'
|
|
7
|
+
|
|
8
|
+
import React from 'react'
|
|
9
|
+
import { formatRelativeDate } from '@/themes/crm/lib/crm-utils'
|
|
10
|
+
import {
|
|
11
|
+
Phone,
|
|
12
|
+
Mail,
|
|
13
|
+
Users,
|
|
14
|
+
CheckSquare,
|
|
15
|
+
FileText,
|
|
16
|
+
Presentation,
|
|
17
|
+
Monitor,
|
|
18
|
+
Clock,
|
|
19
|
+
Building2,
|
|
20
|
+
User,
|
|
21
|
+
type LucideIcon
|
|
22
|
+
} from 'lucide-react'
|
|
23
|
+
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
24
|
+
|
|
25
|
+
export interface Activity {
|
|
26
|
+
id: string
|
|
27
|
+
type: 'call' | 'email' | 'meeting' | 'task' | 'note' | 'demo' | 'presentation'
|
|
28
|
+
subject: string
|
|
29
|
+
description?: string
|
|
30
|
+
status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'overdue'
|
|
31
|
+
priority: 'low' | 'medium' | 'high' | 'urgent'
|
|
32
|
+
dueDate: string | Date
|
|
33
|
+
completedAt?: string | Date
|
|
34
|
+
assignedTo?: string
|
|
35
|
+
assignedToName?: string
|
|
36
|
+
contactId?: string
|
|
37
|
+
contactName?: string
|
|
38
|
+
companyId?: string
|
|
39
|
+
companyName?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ActivityCardProps {
|
|
43
|
+
activity: Activity
|
|
44
|
+
onClick?: () => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Type configuration with icons and colors
|
|
48
|
+
const activityTypeConfig: Record<Activity['type'], { icon: LucideIcon; bgClass: string; textClass: string }> = {
|
|
49
|
+
call: { icon: Phone, bgClass: 'bg-[oklch(0.55_0.22_286/0.15)]', textClass: 'text-[oklch(0.55_0.22_286)]' },
|
|
50
|
+
email: { icon: Mail, bgClass: 'bg-[oklch(0.55_0.18_150/0.15)]', textClass: 'text-[oklch(0.55_0.18_150)]' },
|
|
51
|
+
meeting: { icon: Users, bgClass: 'bg-[oklch(0.65_0.18_80/0.15)]', textClass: 'text-[oklch(0.65_0.18_80)]' },
|
|
52
|
+
task: { icon: CheckSquare, bgClass: 'bg-[oklch(0.55_0.2_320/0.15)]', textClass: 'text-[oklch(0.55_0.2_320)]' },
|
|
53
|
+
note: { icon: FileText, bgClass: 'bg-muted', textClass: 'text-muted-foreground' },
|
|
54
|
+
demo: { icon: Monitor, bgClass: 'bg-[oklch(0.55_0.22_286/0.15)]', textClass: 'text-[oklch(0.55_0.22_286)]' },
|
|
55
|
+
presentation: { icon: Presentation, bgClass: 'bg-[oklch(0.65_0.18_80/0.15)]', textClass: 'text-[oklch(0.65_0.18_80)]' },
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Status badge configuration
|
|
59
|
+
const statusConfig: Record<Activity['status'], { label: string; className: string }> = {
|
|
60
|
+
scheduled: { label: 'Scheduled', className: 'bg-primary/10 text-primary' },
|
|
61
|
+
in_progress: { label: 'In Progress', className: 'bg-amber-500/10 text-amber-600' },
|
|
62
|
+
completed: { label: 'Completed', className: 'bg-emerald-500/10 text-emerald-600' },
|
|
63
|
+
cancelled: { label: 'Cancelled', className: 'bg-muted text-muted-foreground' },
|
|
64
|
+
overdue: { label: 'Overdue', className: 'bg-destructive/10 text-destructive' },
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Priority indicator configuration
|
|
68
|
+
const priorityConfig: Record<Activity['priority'], { className: string }> = {
|
|
69
|
+
low: { className: 'bg-muted-foreground/50' },
|
|
70
|
+
medium: { className: 'bg-amber-500' },
|
|
71
|
+
high: { className: 'bg-destructive' },
|
|
72
|
+
urgent: { className: 'bg-destructive shadow-[0_0_8px_oklch(0.55_0.22_25)]' },
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Default fallback config for unknown activity types
|
|
76
|
+
const defaultTypeConfig = { icon: FileText, bgClass: 'bg-muted', textClass: 'text-muted-foreground' }
|
|
77
|
+
const defaultStatusConfig = { label: 'Unknown', className: 'bg-muted text-muted-foreground' }
|
|
78
|
+
const defaultPriorityConfig = { className: 'bg-muted-foreground/50' }
|
|
79
|
+
|
|
80
|
+
export function ActivityCard({ activity, onClick }: ActivityCardProps) {
|
|
81
|
+
const typeConfig = activityTypeConfig[activity.type] || defaultTypeConfig
|
|
82
|
+
const status = statusConfig[activity.status] || defaultStatusConfig
|
|
83
|
+
const priority = priorityConfig[activity.priority] || defaultPriorityConfig
|
|
84
|
+
const Icon = typeConfig.icon
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="relative flex gap-4 pb-6 last:pb-0 group">
|
|
88
|
+
{/* Timeline line */}
|
|
89
|
+
<div className="absolute left-5 top-10 bottom-0 w-px bg-border group-last:hidden" />
|
|
90
|
+
|
|
91
|
+
{/* Icon marker */}
|
|
92
|
+
<div className={cn(
|
|
93
|
+
'relative z-10 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl shadow-sm',
|
|
94
|
+
typeConfig.bgClass
|
|
95
|
+
)}>
|
|
96
|
+
<Icon className={cn('h-5 w-5', typeConfig.textClass)} />
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{/* Content card */}
|
|
100
|
+
<div
|
|
101
|
+
className={cn(
|
|
102
|
+
'flex-1 rounded-xl border bg-card p-4 shadow-sm transition-all duration-200',
|
|
103
|
+
onClick && 'cursor-pointer hover:shadow-md hover:border-primary/30'
|
|
104
|
+
)}
|
|
105
|
+
onClick={onClick}
|
|
106
|
+
role={onClick ? 'button' : undefined}
|
|
107
|
+
tabIndex={onClick ? 0 : undefined}
|
|
108
|
+
>
|
|
109
|
+
{/* Header */}
|
|
110
|
+
<div className="flex items-start justify-between gap-3 mb-2">
|
|
111
|
+
<div className="flex-1 min-w-0">
|
|
112
|
+
<div className="flex items-center gap-2 mb-1">
|
|
113
|
+
<span className={cn('w-2 h-2 rounded-full shrink-0', priority.className)} />
|
|
114
|
+
<h4 className="font-semibold text-sm text-foreground truncate">
|
|
115
|
+
{activity.subject}
|
|
116
|
+
</h4>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{activity.description && (
|
|
120
|
+
<p className="text-sm text-muted-foreground line-clamp-2 leading-relaxed">
|
|
121
|
+
{activity.description}
|
|
122
|
+
</p>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<span className={cn(
|
|
127
|
+
'shrink-0 px-2.5 py-1 rounded-md text-xs font-medium capitalize',
|
|
128
|
+
status.className
|
|
129
|
+
)}>
|
|
130
|
+
{status.label}
|
|
131
|
+
</span>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
{/* Meta information */}
|
|
135
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 mt-3 pt-3 border-t border-border/50">
|
|
136
|
+
{activity.assignedToName && (
|
|
137
|
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
138
|
+
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center text-[10px] font-semibold text-primary">
|
|
139
|
+
{activity.assignedToName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()}
|
|
140
|
+
</div>
|
|
141
|
+
<span>{activity.assignedToName}</span>
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{activity.companyName && (
|
|
146
|
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
147
|
+
<Building2 className="w-3.5 h-3.5" />
|
|
148
|
+
<span>{activity.companyName}</span>
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{activity.contactName && (
|
|
153
|
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
154
|
+
<User className="w-3.5 h-3.5" />
|
|
155
|
+
<span>{activity.contactName}</span>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground ml-auto">
|
|
160
|
+
<Clock className="w-3.5 h-3.5" />
|
|
161
|
+
<span>{formatRelativeDate(activity.dueDate)}</span>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export default ActivityCard
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activity Timeline Component
|
|
3
|
+
* Professional timeline view with grouped dates and animations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client'
|
|
7
|
+
|
|
8
|
+
import React from 'react'
|
|
9
|
+
import { ActivityCard, type Activity } from './ActivityCard'
|
|
10
|
+
import { CalendarDays, Inbox } from 'lucide-react'
|
|
11
|
+
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
12
|
+
|
|
13
|
+
interface ActivityTimelineProps {
|
|
14
|
+
activities: Activity[]
|
|
15
|
+
onActivityClick?: (activity: Activity) => void
|
|
16
|
+
groupByDate?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ActivityTimeline({
|
|
20
|
+
activities,
|
|
21
|
+
onActivityClick,
|
|
22
|
+
groupByDate = true,
|
|
23
|
+
}: ActivityTimelineProps) {
|
|
24
|
+
// Group activities by date
|
|
25
|
+
const groupedActivities = activities.reduce((acc, activity) => {
|
|
26
|
+
const date = new Date(activity.dueDate)
|
|
27
|
+
const dateKey = date.toDateString()
|
|
28
|
+
|
|
29
|
+
if (!acc[dateKey]) {
|
|
30
|
+
acc[dateKey] = []
|
|
31
|
+
}
|
|
32
|
+
acc[dateKey].push(activity)
|
|
33
|
+
return acc
|
|
34
|
+
}, {} as Record<string, Activity[]>)
|
|
35
|
+
|
|
36
|
+
// Sort date keys
|
|
37
|
+
const sortedDates = Object.keys(groupedActivities).sort((a, b) => {
|
|
38
|
+
return new Date(a).getTime() - new Date(b).getTime()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const formatDateHeader = (dateStr: string): { main: string; sub?: string; isToday?: boolean; isPast?: boolean } => {
|
|
42
|
+
const date = new Date(dateStr)
|
|
43
|
+
const today = new Date()
|
|
44
|
+
const tomorrow = new Date(today)
|
|
45
|
+
tomorrow.setDate(tomorrow.getDate() + 1)
|
|
46
|
+
const yesterday = new Date(today)
|
|
47
|
+
yesterday.setDate(yesterday.getDate() - 1)
|
|
48
|
+
|
|
49
|
+
const isToday = date.toDateString() === today.toDateString()
|
|
50
|
+
const isPast = date < today && !isToday
|
|
51
|
+
|
|
52
|
+
if (isToday) {
|
|
53
|
+
return { main: 'Today', sub: date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), isToday: true }
|
|
54
|
+
}
|
|
55
|
+
if (date.toDateString() === tomorrow.toDateString()) {
|
|
56
|
+
return { main: 'Tomorrow', sub: date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) }
|
|
57
|
+
}
|
|
58
|
+
if (date.toDateString() === yesterday.toDateString()) {
|
|
59
|
+
return { main: 'Yesterday', sub: date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), isPast: true }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
main: date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }),
|
|
64
|
+
sub: date.getFullYear() !== today.getFullYear() ? date.getFullYear().toString() : undefined,
|
|
65
|
+
isPast
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (activities.length === 0) {
|
|
70
|
+
return (
|
|
71
|
+
<div className="flex flex-col items-center justify-center py-16 px-4">
|
|
72
|
+
<div className="w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mb-4">
|
|
73
|
+
<Inbox className="w-8 h-8 text-muted-foreground" />
|
|
74
|
+
</div>
|
|
75
|
+
<h3 className="text-lg font-semibold text-foreground mb-2">
|
|
76
|
+
No activities yet
|
|
77
|
+
</h3>
|
|
78
|
+
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
|
79
|
+
Schedule calls, meetings, and tasks to keep track of your sales activities.
|
|
80
|
+
</p>
|
|
81
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!groupByDate) {
|
|
86
|
+
return (
|
|
87
|
+
<div className="space-y-0">
|
|
88
|
+
{activities.map((activity, index) => (
|
|
89
|
+
<div
|
|
90
|
+
key={activity.id}
|
|
91
|
+
className="animate-in fade-in slide-in-from-bottom-2"
|
|
92
|
+
style={{ animationDelay: `${index * 50}ms`, animationFillMode: 'backwards' }}
|
|
93
|
+
>
|
|
94
|
+
<ActivityCard
|
|
95
|
+
activity={activity}
|
|
96
|
+
onClick={() => onActivityClick?.(activity)}
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="space-y-8">
|
|
106
|
+
{sortedDates.map((dateKey, groupIndex) => {
|
|
107
|
+
const dateInfo = formatDateHeader(dateKey)
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
key={dateKey}
|
|
112
|
+
className="animate-in fade-in slide-in-from-bottom-3"
|
|
113
|
+
style={{ animationDelay: `${groupIndex * 100}ms`, animationFillMode: 'backwards' }}
|
|
114
|
+
>
|
|
115
|
+
{/* Date header */}
|
|
116
|
+
<div className="flex items-center gap-3 mb-4">
|
|
117
|
+
<div className={cn(
|
|
118
|
+
'flex items-center justify-center w-9 h-9 rounded-lg',
|
|
119
|
+
dateInfo.isToday ? 'bg-primary text-primary-foreground' : 'bg-muted'
|
|
120
|
+
)}>
|
|
121
|
+
<CalendarDays className={cn(
|
|
122
|
+
'w-4 h-4',
|
|
123
|
+
dateInfo.isToday ? 'text-primary-foreground' : 'text-muted-foreground'
|
|
124
|
+
)} />
|
|
125
|
+
</div>
|
|
126
|
+
<div>
|
|
127
|
+
<h3 className={cn(
|
|
128
|
+
'text-base font-semibold',
|
|
129
|
+
dateInfo.isToday ? 'text-primary' : dateInfo.isPast ? 'text-muted-foreground' : 'text-foreground'
|
|
130
|
+
)}>
|
|
131
|
+
{dateInfo.main}
|
|
132
|
+
</h3>
|
|
133
|
+
{dateInfo.sub && (
|
|
134
|
+
<p className="text-xs text-muted-foreground">{dateInfo.sub}</p>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
<div className="flex-1 h-px bg-border ml-2" />
|
|
138
|
+
<span className="text-xs font-medium text-muted-foreground bg-muted px-2 py-1 rounded-md">
|
|
139
|
+
{groupedActivities[dateKey].length} {groupedActivities[dateKey].length === 1 ? 'activity' : 'activities'}
|
|
140
|
+
</span>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{/* Activities list */}
|
|
144
|
+
<div className="space-y-0 pl-1">
|
|
145
|
+
{groupedActivities[dateKey].map((activity, index) => (
|
|
146
|
+
<div
|
|
147
|
+
key={activity.id}
|
|
148
|
+
className="animate-in fade-in slide-in-from-left-2"
|
|
149
|
+
style={{ animationDelay: `${(groupIndex * 100) + (index * 50)}ms`, animationFillMode: 'backwards' }}
|
|
150
|
+
>
|
|
151
|
+
<ActivityCard
|
|
152
|
+
activity={activity}
|
|
153
|
+
onClick={() => onActivityClick?.(activity)}
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
))}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
})}
|
|
161
|
+
</div>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export default ActivityTimeline
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activities Page
|
|
3
|
+
* Professional activities management with timeline view and stats
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client'
|
|
7
|
+
|
|
8
|
+
import { useRouter } from 'next/navigation'
|
|
9
|
+
import { useEffect, useState } from 'react'
|
|
10
|
+
import { ActivityTimeline } from './components/ActivityTimeline'
|
|
11
|
+
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
12
|
+
import {
|
|
13
|
+
Plus,
|
|
14
|
+
CalendarDays,
|
|
15
|
+
LayoutList,
|
|
16
|
+
Phone,
|
|
17
|
+
Mail,
|
|
18
|
+
Users,
|
|
19
|
+
CheckSquare,
|
|
20
|
+
FileText,
|
|
21
|
+
Activity,
|
|
22
|
+
Clock,
|
|
23
|
+
CheckCircle2
|
|
24
|
+
} from 'lucide-react'
|
|
25
|
+
import type { Activity as ActivityType } from './components/ActivityCard'
|
|
26
|
+
import { fetchWithTeam } from '@nextsparkjs/core/lib/api/entities'
|
|
27
|
+
import { useTeamContext } from '@nextsparkjs/core/contexts/TeamContext'
|
|
28
|
+
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
29
|
+
|
|
30
|
+
type ViewMode = 'timeline' | 'list'
|
|
31
|
+
type FilterType = 'all' | 'call' | 'email' | 'meeting' | 'task' | 'note'
|
|
32
|
+
|
|
33
|
+
// Filter configuration with icons
|
|
34
|
+
const filterConfig: Record<FilterType, { icon: typeof Phone; label: string }> = {
|
|
35
|
+
all: { icon: Activity, label: 'All' },
|
|
36
|
+
call: { icon: Phone, label: 'Calls' },
|
|
37
|
+
email: { icon: Mail, label: 'Emails' },
|
|
38
|
+
meeting: { icon: Users, label: 'Meetings' },
|
|
39
|
+
task: { icon: CheckSquare, label: 'Tasks' },
|
|
40
|
+
note: { icon: FileText, label: 'Notes' },
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function ActivitiesPage() {
|
|
44
|
+
const router = useRouter()
|
|
45
|
+
const { currentTeam, isLoading: teamLoading } = useTeamContext()
|
|
46
|
+
const [activities, setActivities] = useState<ActivityType[]>([])
|
|
47
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
48
|
+
const [viewMode, setViewMode] = useState<ViewMode>('timeline')
|
|
49
|
+
const [filter, setFilter] = useState<FilterType>('all')
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (teamLoading || !currentTeam) return
|
|
53
|
+
|
|
54
|
+
async function fetchActivities() {
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetchWithTeam('/api/v1/activities')
|
|
57
|
+
if (!response.ok) throw new Error('Failed to fetch activities')
|
|
58
|
+
const result = await response.json()
|
|
59
|
+
const data = result.data || []
|
|
60
|
+
|
|
61
|
+
const transformedActivities: ActivityType[] = data
|
|
62
|
+
.map((act: any) => ({
|
|
63
|
+
id: act.id,
|
|
64
|
+
type: act.type,
|
|
65
|
+
subject: act.subject,
|
|
66
|
+
description: act.description,
|
|
67
|
+
status: act.status,
|
|
68
|
+
priority: act.priority || 'medium',
|
|
69
|
+
dueDate: act.dueDate,
|
|
70
|
+
completedAt: act.completedAt,
|
|
71
|
+
assignedTo: act.assignedTo,
|
|
72
|
+
assignedToName: act.assignedToName,
|
|
73
|
+
contactId: act.contactId,
|
|
74
|
+
contactName: act.contactName,
|
|
75
|
+
companyId: act.companyId,
|
|
76
|
+
companyName: act.companyName,
|
|
77
|
+
}))
|
|
78
|
+
.sort((a: ActivityType, b: ActivityType) =>
|
|
79
|
+
new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
setActivities(transformedActivities)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error('Error loading activities:', error)
|
|
85
|
+
} finally {
|
|
86
|
+
setIsLoading(false)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fetchActivities()
|
|
91
|
+
}, [teamLoading, currentTeam])
|
|
92
|
+
|
|
93
|
+
const handleActivityClick = (activity: ActivityType) => {
|
|
94
|
+
router.push(`/dashboard/activities/${activity.id}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const handleAddActivity = () => {
|
|
98
|
+
router.push('/dashboard/activities/create')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Filter activities by type
|
|
102
|
+
const filteredActivities = filter === 'all'
|
|
103
|
+
? activities
|
|
104
|
+
: activities.filter(act => act.type === filter)
|
|
105
|
+
|
|
106
|
+
// Calculate stats
|
|
107
|
+
const stats = {
|
|
108
|
+
total: activities.length,
|
|
109
|
+
scheduled: activities.filter(a => a.status === 'scheduled').length,
|
|
110
|
+
completed: activities.filter(a => a.status === 'completed').length,
|
|
111
|
+
overdue: activities.filter(a => a.status === 'overdue').length,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Calculate filter counts
|
|
115
|
+
const filterCounts: Record<FilterType, number> = {
|
|
116
|
+
all: activities.length,
|
|
117
|
+
call: activities.filter(a => a.type === 'call').length,
|
|
118
|
+
email: activities.filter(a => a.type === 'email').length,
|
|
119
|
+
meeting: activities.filter(a => a.type === 'meeting').length,
|
|
120
|
+
task: activities.filter(a => a.type === 'task').length,
|
|
121
|
+
note: activities.filter(a => a.type === 'note').length,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (isLoading) {
|
|
125
|
+
return (
|
|
126
|
+
<div className="p-6 space-y-6">
|
|
127
|
+
{/* Header skeleton */}
|
|
128
|
+
<div className="flex items-center justify-between">
|
|
129
|
+
<div className="space-y-2">
|
|
130
|
+
<div className="h-8 w-48 bg-muted animate-pulse rounded-lg" />
|
|
131
|
+
<div className="h-4 w-64 bg-muted animate-pulse rounded-md" />
|
|
132
|
+
</div>
|
|
133
|
+
<div className="h-10 w-36 bg-muted animate-pulse rounded-lg" />
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{/* Stats skeleton */}
|
|
137
|
+
<div className="grid grid-cols-4 gap-4">
|
|
138
|
+
{[1, 2, 3, 4].map(i => (
|
|
139
|
+
<div key={i} className="h-20 bg-muted animate-pulse rounded-xl" />
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{/* Content skeleton */}
|
|
144
|
+
<div className="space-y-4">
|
|
145
|
+
{[1, 2, 3].map(i => (
|
|
146
|
+
<div key={i} className="h-28 bg-muted animate-pulse rounded-xl" />
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div className="p-6 space-y-6">
|
|
155
|
+
{/* Header */}
|
|
156
|
+
<div className="flex items-start justify-between">
|
|
157
|
+
<div>
|
|
158
|
+
<h1 className="text-2xl font-bold text-foreground tracking-tight">
|
|
159
|
+
Activities
|
|
160
|
+
</h1>
|
|
161
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
162
|
+
Track and manage your sales activities
|
|
163
|
+
</p>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<div className="flex items-center gap-3">
|
|
167
|
+
{/* View toggle */}
|
|
168
|
+
<div className="flex p-1 bg-muted rounded-lg">
|
|
169
|
+
<button
|
|
170
|
+
className={cn(
|
|
171
|
+
'flex items-center justify-center w-9 h-9 rounded-md transition-all',
|
|
172
|
+
viewMode === 'timeline'
|
|
173
|
+
? 'bg-background shadow-sm text-primary'
|
|
174
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
175
|
+
)}
|
|
176
|
+
onClick={() => setViewMode('timeline')}
|
|
177
|
+
title="Timeline view"
|
|
178
|
+
>
|
|
179
|
+
<CalendarDays className="w-4 h-4" />
|
|
180
|
+
</button>
|
|
181
|
+
<button
|
|
182
|
+
className={cn(
|
|
183
|
+
'flex items-center justify-center w-9 h-9 rounded-md transition-all',
|
|
184
|
+
viewMode === 'list'
|
|
185
|
+
? 'bg-background shadow-sm text-primary'
|
|
186
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
187
|
+
)}
|
|
188
|
+
onClick={() => setViewMode('list')}
|
|
189
|
+
title="List view"
|
|
190
|
+
>
|
|
191
|
+
<LayoutList className="w-4 h-4" />
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<Button onClick={handleAddActivity} className="gap-2" data-cy="activities-add">
|
|
196
|
+
<Plus className="w-4 h-4" />
|
|
197
|
+
New Activity
|
|
198
|
+
</Button>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{/* Stats cards */}
|
|
203
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
204
|
+
<div className="bg-card border rounded-xl p-4">
|
|
205
|
+
<div className="flex items-center gap-3">
|
|
206
|
+
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
207
|
+
<Activity className="w-5 h-5 text-primary" />
|
|
208
|
+
</div>
|
|
209
|
+
<div>
|
|
210
|
+
<p className="text-2xl font-bold text-foreground">{stats.total}</p>
|
|
211
|
+
<p className="text-xs text-muted-foreground">Total Activities</p>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div className="bg-card border rounded-xl p-4">
|
|
217
|
+
<div className="flex items-center gap-3">
|
|
218
|
+
<div className="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center">
|
|
219
|
+
<Clock className="w-5 h-5 text-amber-600" />
|
|
220
|
+
</div>
|
|
221
|
+
<div>
|
|
222
|
+
<p className="text-2xl font-bold text-foreground">{stats.scheduled}</p>
|
|
223
|
+
<p className="text-xs text-muted-foreground">Scheduled</p>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<div className="bg-card border rounded-xl p-4">
|
|
229
|
+
<div className="flex items-center gap-3">
|
|
230
|
+
<div className="w-10 h-10 rounded-lg bg-emerald-500/10 flex items-center justify-center">
|
|
231
|
+
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
|
|
232
|
+
</div>
|
|
233
|
+
<div>
|
|
234
|
+
<p className="text-2xl font-bold text-foreground">{stats.completed}</p>
|
|
235
|
+
<p className="text-xs text-muted-foreground">Completed</p>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<div className="bg-card border rounded-xl p-4">
|
|
241
|
+
<div className="flex items-center gap-3">
|
|
242
|
+
<div className="w-10 h-10 rounded-lg bg-destructive/10 flex items-center justify-center">
|
|
243
|
+
<Clock className="w-5 h-5 text-destructive" />
|
|
244
|
+
</div>
|
|
245
|
+
<div>
|
|
246
|
+
<p className="text-2xl font-bold text-foreground">{stats.overdue}</p>
|
|
247
|
+
<p className="text-xs text-muted-foreground">Overdue</p>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
{/* Filter pills */}
|
|
254
|
+
<div className="flex flex-wrap gap-2">
|
|
255
|
+
{(Object.keys(filterConfig) as FilterType[]).map((key) => {
|
|
256
|
+
const config = filterConfig[key]
|
|
257
|
+
const Icon = config.icon
|
|
258
|
+
const count = filterCounts[key]
|
|
259
|
+
const isActive = filter === key
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<button
|
|
263
|
+
key={key}
|
|
264
|
+
onClick={() => setFilter(key)}
|
|
265
|
+
className={cn(
|
|
266
|
+
'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all',
|
|
267
|
+
isActive
|
|
268
|
+
? 'bg-primary text-primary-foreground shadow-sm'
|
|
269
|
+
: 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground'
|
|
270
|
+
)}
|
|
271
|
+
>
|
|
272
|
+
<Icon className="w-4 h-4" />
|
|
273
|
+
{config.label}
|
|
274
|
+
<span className={cn(
|
|
275
|
+
'ml-1 px-1.5 py-0.5 rounded text-xs',
|
|
276
|
+
isActive
|
|
277
|
+
? 'bg-primary-foreground/20 text-primary-foreground'
|
|
278
|
+
: 'bg-background text-muted-foreground'
|
|
279
|
+
)}>
|
|
280
|
+
{count}
|
|
281
|
+
</span>
|
|
282
|
+
</button>
|
|
283
|
+
)
|
|
284
|
+
})}
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
{/* Timeline */}
|
|
288
|
+
<div className="mt-2" data-cy="activities-list">
|
|
289
|
+
<ActivityTimeline
|
|
290
|
+
activities={filteredActivities}
|
|
291
|
+
onActivityClick={handleActivityClick}
|
|
292
|
+
groupByDate={viewMode === 'timeline'}
|
|
293
|
+
/>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
)
|
|
297
|
+
}
|