@open-mercato/core 0.5.1-develop.2996.ce62fd491c → 0.5.1-develop.3032.01699048cb
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/customers/api/companies/[id]/route.js +30 -20
- package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/companies/route.js +12 -7
- package/dist/modules/customers/api/companies/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/companies/enriched/route.js +12 -7
- package/dist/modules/customers/api/people/[id]/companies/enriched/route.js.map +2 -2
- package/dist/modules/customers/api/people/route.js +12 -7
- package/dist/modules/customers/api/people/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +21 -0
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +27 -30
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js +56 -0
- package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js.map +7 -0
- package/dist/modules/customers/components/detail/ActivitiesCard.js +175 -0
- package/dist/modules/customers/components/detail/ActivitiesCard.js.map +7 -0
- package/dist/modules/customers/components/detail/ActivitiesDayStrip.js +324 -0
- package/dist/modules/customers/components/detail/ActivitiesDayStrip.js.map +7 -0
- package/dist/modules/customers/components/detail/ActivitiesSection.js +62 -13
- package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityLogTab.js +14 -23
- package/dist/modules/customers/components/detail/ActivityLogTab.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityTimeline.js +13 -13
- package/dist/modules/customers/components/detail/ActivityTimeline.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityTimelineFilters.js +35 -22
- package/dist/modules/customers/components/detail/ActivityTimelineFilters.js.map +2 -2
- package/dist/modules/customers/components/detail/AiActionChips.js +15 -22
- package/dist/modules/customers/components/detail/AiActionChips.js.map +2 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +196 -28
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/DateTimeFields.js +2 -2
- package/dist/modules/customers/components/detail/schedule/DateTimeFields.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/FooterFields.js +14 -2
- package/dist/modules/customers/components/detail/schedule/FooterFields.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/LinkedEntitiesField.js +9 -2
- package/dist/modules/customers/components/detail/schedule/LinkedEntitiesField.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +9 -2
- package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/fieldConfig.js +25 -4
- package/dist/modules/customers/components/detail/schedule/fieldConfig.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +20 -3
- package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
- package/package.json +3 -3
- package/src/modules/customers/api/companies/[id]/route.ts +30 -20
- package/src/modules/customers/api/companies/route.ts +12 -7
- package/src/modules/customers/api/people/[id]/companies/enriched/route.ts +12 -7
- package/src/modules/customers/api/people/route.ts +12 -7
- package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +22 -0
- package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +28 -21
- package/src/modules/customers/components/detail/ActivitiesAddNewMenu.tsx +67 -0
- package/src/modules/customers/components/detail/ActivitiesCard.tsx +231 -0
- package/src/modules/customers/components/detail/ActivitiesDayStrip.tsx +390 -0
- package/src/modules/customers/components/detail/ActivitiesSection.tsx +91 -40
- package/src/modules/customers/components/detail/ActivityLogTab.tsx +25 -23
- package/src/modules/customers/components/detail/ActivityTimeline.tsx +15 -19
- package/src/modules/customers/components/detail/ActivityTimelineFilters.tsx +36 -29
- package/src/modules/customers/components/detail/AiActionChips.tsx +17 -23
- package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +233 -41
- package/src/modules/customers/components/detail/schedule/DateTimeFields.tsx +6 -2
- package/src/modules/customers/components/detail/schedule/FooterFields.tsx +22 -2
- package/src/modules/customers/components/detail/schedule/LinkedEntitiesField.tsx +10 -2
- package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +10 -2
- package/src/modules/customers/components/detail/schedule/fieldConfig.ts +26 -6
- package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +32 -3
- package/src/modules/customers/i18n/de.json +69 -2
- package/src/modules/customers/i18n/en.json +69 -2
- package/src/modules/customers/i18n/es.json +69 -2
- package/src/modules/customers/i18n/pl.json +68 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Calendar, CalendarClock, Clock, Mail, Phone, StickyNote, Users } from 'lucide-react'
|
|
5
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
6
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
8
|
+
import { ActivitiesDayStrip } from './ActivitiesDayStrip'
|
|
9
|
+
import { ActivitiesAddNewMenu, type ActivityKind } from './ActivitiesAddNewMenu'
|
|
10
|
+
import type { InteractionSummary } from './types'
|
|
11
|
+
|
|
12
|
+
interface ActivitiesCardProps {
|
|
13
|
+
entityId: string
|
|
14
|
+
plannedActivities: InteractionSummary[]
|
|
15
|
+
refreshKey?: number
|
|
16
|
+
onAddNew: (kind: ActivityKind) => void
|
|
17
|
+
onEditActivity?: (activity: InteractionSummary) => void
|
|
18
|
+
/**
|
|
19
|
+
* Optional company name for the parent entity. When the planned activity has no `dealTitle`,
|
|
20
|
+
* the row subtitle falls back to "{type} · {company}" to mirror Figma 784:809.
|
|
21
|
+
*/
|
|
22
|
+
entityCompanyName?: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
26
|
+
call: Phone,
|
|
27
|
+
email: Mail,
|
|
28
|
+
meeting: Users,
|
|
29
|
+
note: StickyNote,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function startOfDay(date: Date): Date {
|
|
33
|
+
const next = new Date(date)
|
|
34
|
+
next.setHours(0, 0, 0, 0)
|
|
35
|
+
return next
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isSameDay(a: Date, b: Date): boolean {
|
|
39
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isOverdue(activity: InteractionSummary, now: Date): boolean {
|
|
43
|
+
const scheduled = activity.scheduledAt ?? activity.occurredAt
|
|
44
|
+
if (!scheduled) return false
|
|
45
|
+
const date = new Date(scheduled)
|
|
46
|
+
if (Number.isNaN(date.getTime())) return false
|
|
47
|
+
return date.getTime() < now.getTime() && activity.status !== 'done'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatTime(date: Date): string {
|
|
51
|
+
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatRelativeDay(date: Date, t: TranslateFn): string {
|
|
55
|
+
const now = new Date()
|
|
56
|
+
const today = startOfDay(now)
|
|
57
|
+
const target = startOfDay(date)
|
|
58
|
+
const diff = Math.round((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
|
59
|
+
if (diff === 0) return t('customers.timeline.date.today', 'today')
|
|
60
|
+
if (diff === 1) return t('customers.timeline.date.tomorrow', 'tomorrow')
|
|
61
|
+
if (diff === -1) return t('customers.timeline.date.yesterday', 'yesterday')
|
|
62
|
+
return target.toLocaleDateString(undefined, { day: 'numeric', month: 'short' })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatDuration(minutes: number, t: TranslateFn): string {
|
|
66
|
+
if (minutes >= 60) {
|
|
67
|
+
const hours = Math.round((minutes / 60) * 10) / 10
|
|
68
|
+
return t('customers.activities.calendar.hoursShort', '{hours}h', { hours })
|
|
69
|
+
}
|
|
70
|
+
return t('customers.activities.calendar.minutesShort', '{minutes}m', { minutes })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function ActivitiesCard({
|
|
74
|
+
entityId,
|
|
75
|
+
plannedActivities,
|
|
76
|
+
refreshKey = 0,
|
|
77
|
+
onAddNew,
|
|
78
|
+
onEditActivity,
|
|
79
|
+
entityCompanyName,
|
|
80
|
+
}: ActivitiesCardProps) {
|
|
81
|
+
const t = useT()
|
|
82
|
+
const [selectedDate, setSelectedDate] = React.useState<Date>(() => startOfDay(new Date()))
|
|
83
|
+
|
|
84
|
+
const eventsForSelectedDay = React.useMemo(() => {
|
|
85
|
+
const items = plannedActivities.filter((activity) => {
|
|
86
|
+
const scheduled = activity.scheduledAt ?? activity.occurredAt
|
|
87
|
+
if (!scheduled) return false
|
|
88
|
+
const date = new Date(scheduled)
|
|
89
|
+
if (Number.isNaN(date.getTime())) return false
|
|
90
|
+
return isSameDay(date, selectedDate)
|
|
91
|
+
})
|
|
92
|
+
return items.sort((left, right) => {
|
|
93
|
+
const leftTime = new Date(left.scheduledAt ?? left.occurredAt ?? left.createdAt).getTime()
|
|
94
|
+
const rightTime = new Date(right.scheduledAt ?? right.occurredAt ?? right.createdAt).getTime()
|
|
95
|
+
return leftTime - rightTime
|
|
96
|
+
})
|
|
97
|
+
}, [plannedActivities, selectedDate])
|
|
98
|
+
|
|
99
|
+
const overdueCount = React.useMemo(() => {
|
|
100
|
+
const now = new Date()
|
|
101
|
+
return plannedActivities.filter((activity) => isOverdue(activity, now)).length
|
|
102
|
+
}, [plannedActivities])
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="flex flex-col gap-3 rounded-lg border border-border bg-card pt-4 pb-4 px-4">
|
|
106
|
+
<div className="flex items-center justify-between">
|
|
107
|
+
<div className="flex items-center gap-2">
|
|
108
|
+
<Calendar className="size-4 text-foreground" />
|
|
109
|
+
<h3 className="text-sm font-semibold leading-none text-foreground">
|
|
110
|
+
{t('customers.activities.card.title', 'Activities')}
|
|
111
|
+
</h3>
|
|
112
|
+
{overdueCount > 0 ? (
|
|
113
|
+
<span className="inline-flex items-center gap-1 rounded-full bg-status-error-bg px-1.5 py-0.5 text-xs font-medium text-status-error-text">
|
|
114
|
+
<CalendarClock className="size-3" />
|
|
115
|
+
{t('customers.activities.card.overdue', '{count} overdue', { count: overdueCount })}
|
|
116
|
+
</span>
|
|
117
|
+
) : null}
|
|
118
|
+
</div>
|
|
119
|
+
<ActivitiesAddNewMenu onSelect={onAddNew} />
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<ActivitiesDayStrip
|
|
123
|
+
entityId={entityId}
|
|
124
|
+
selectedDate={selectedDate}
|
|
125
|
+
onSelectDate={setSelectedDate}
|
|
126
|
+
refreshKey={refreshKey}
|
|
127
|
+
/>
|
|
128
|
+
|
|
129
|
+
{eventsForSelectedDay.length > 0 ? (
|
|
130
|
+
<>
|
|
131
|
+
<div className="h-px w-full bg-border" />
|
|
132
|
+
<ul className="flex flex-col">
|
|
133
|
+
{eventsForSelectedDay.map((activity) => (
|
|
134
|
+
<PlannedEventRow
|
|
135
|
+
key={activity.id}
|
|
136
|
+
activity={activity}
|
|
137
|
+
onClick={onEditActivity}
|
|
138
|
+
entityCompanyName={entityCompanyName ?? null}
|
|
139
|
+
t={t}
|
|
140
|
+
/>
|
|
141
|
+
))}
|
|
142
|
+
</ul>
|
|
143
|
+
</>
|
|
144
|
+
) : (
|
|
145
|
+
<>
|
|
146
|
+
<div className="h-px w-full bg-border" />
|
|
147
|
+
<p className="px-1 py-2 text-xs text-muted-foreground">
|
|
148
|
+
{t('customers.activities.card.empty', 'Nothing scheduled for this day.')}
|
|
149
|
+
</p>
|
|
150
|
+
</>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface PlannedEventRowProps {
|
|
157
|
+
activity: InteractionSummary
|
|
158
|
+
onClick?: (activity: InteractionSummary) => void
|
|
159
|
+
entityCompanyName: string | null
|
|
160
|
+
t: TranslateFn
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function PlannedEventRow({ activity, onClick, entityCompanyName, t }: PlannedEventRowProps) {
|
|
164
|
+
const dateStr = activity.scheduledAt ?? activity.occurredAt ?? activity.createdAt
|
|
165
|
+
const date = new Date(dateStr)
|
|
166
|
+
const validDate = !Number.isNaN(date.getTime())
|
|
167
|
+
const Icon = TYPE_ICONS[activity.interactionType] ?? Users
|
|
168
|
+
const duration = typeof activity.duration === 'number' && activity.duration > 0 ? activity.duration : null
|
|
169
|
+
const overdue = validDate && date.getTime() < Date.now() && activity.status !== 'done'
|
|
170
|
+
const typeLabel = labelForType(activity.interactionType, t)
|
|
171
|
+
const subtitleSuffix = activity.dealTitle ?? entityCompanyName ?? null
|
|
172
|
+
const subtitle = subtitleSuffix ? `${typeLabel} · ${subtitleSuffix}` : typeLabel
|
|
173
|
+
const interactive = !!onClick
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<li>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
onClick={interactive ? () => onClick?.(activity) : undefined}
|
|
180
|
+
disabled={!interactive}
|
|
181
|
+
className={cn(
|
|
182
|
+
'flex w-full items-start gap-[9px] pt-[8px] text-left transition-colors',
|
|
183
|
+
interactive ? 'cursor-pointer rounded-md hover:bg-accent/30 px-1' : 'px-1',
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
<div className="flex h-[44px] w-[43px] shrink-0 flex-col gap-[2px] pt-[2px]">
|
|
187
|
+
<span className="text-xs font-semibold leading-none text-foreground">
|
|
188
|
+
{validDate ? formatTime(date) : ''}
|
|
189
|
+
</span>
|
|
190
|
+
<span className="text-[10px] leading-none font-normal text-muted-foreground">
|
|
191
|
+
{validDate ? formatRelativeDay(date, t) : ''}
|
|
192
|
+
</span>
|
|
193
|
+
</div>
|
|
194
|
+
<div className="flex shrink-0 items-center justify-center rounded-full bg-muted border-4 border-background size-7">
|
|
195
|
+
<Icon className="size-4 text-muted-foreground" />
|
|
196
|
+
</div>
|
|
197
|
+
<div className="min-w-0 flex flex-1 flex-col gap-[4px]">
|
|
198
|
+
<span className="text-sm leading-5 tracking-[-0.084px] text-foreground">
|
|
199
|
+
{activity.title ?? activity.body ?? labelForType(activity.interactionType, t)}
|
|
200
|
+
</span>
|
|
201
|
+
{duration ? (
|
|
202
|
+
<span className={cn(
|
|
203
|
+
'inline-flex w-fit items-center gap-[2px] rounded-full pl-[4px] pr-[8px] py-[2px] text-xs font-medium leading-[16px]',
|
|
204
|
+
overdue
|
|
205
|
+
? 'bg-status-error-bg text-status-error-text'
|
|
206
|
+
: 'bg-status-warning-bg text-status-warning-text',
|
|
207
|
+
)}>
|
|
208
|
+
<Clock className="size-4" />
|
|
209
|
+
{formatDuration(duration, t)}
|
|
210
|
+
</span>
|
|
211
|
+
) : null}
|
|
212
|
+
<span className="text-[11px] font-normal text-muted-foreground">{subtitle}</span>
|
|
213
|
+
</div>
|
|
214
|
+
</button>
|
|
215
|
+
</li>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function labelForType(type: string, t: TranslateFn): string {
|
|
220
|
+
const map: Record<string, [string, string]> = {
|
|
221
|
+
meeting: ['customers.timeline.filter.meeting', 'Meeting'],
|
|
222
|
+
call: ['customers.timeline.filter.call', 'Call'],
|
|
223
|
+
email: ['customers.timeline.filter.email', 'Email'],
|
|
224
|
+
note: ['customers.timeline.filter.note', 'Note'],
|
|
225
|
+
task: ['customers.timeline.filter.task', 'Task'],
|
|
226
|
+
}
|
|
227
|
+
const entry = map[type]
|
|
228
|
+
return entry ? t(entry[0], entry[1]) : type
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export default ActivitiesCard
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
|
5
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
6
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
8
|
+
import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
9
|
+
import type { InteractionSummary } from './types'
|
|
10
|
+
|
|
11
|
+
interface ActivitiesDayStripProps {
|
|
12
|
+
entityId: string
|
|
13
|
+
selectedDate: Date
|
|
14
|
+
onSelectDate: (date: Date) => void
|
|
15
|
+
refreshKey?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const VISIBLE_DAYS = 5
|
|
19
|
+
const BUSYNESS_SLOTS = 10
|
|
20
|
+
const SLOT_START_HOUR = 7
|
|
21
|
+
const SLOT_END_HOUR = 22
|
|
22
|
+
|
|
23
|
+
const DAY_LABEL_KEYS: Array<[number, string, string]> = [
|
|
24
|
+
[0, 'customers.calendar.day.sun', 'SUN'],
|
|
25
|
+
[1, 'customers.calendar.day.mon', 'MON'],
|
|
26
|
+
[2, 'customers.calendar.day.tue', 'TUE'],
|
|
27
|
+
[3, 'customers.calendar.day.wed', 'WED'],
|
|
28
|
+
[4, 'customers.calendar.day.thu', 'THU'],
|
|
29
|
+
[5, 'customers.calendar.day.fri', 'FRI'],
|
|
30
|
+
[6, 'customers.calendar.day.sat', 'SAT'],
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const MONTH_KEYS: Array<[number, string, string]> = [
|
|
34
|
+
[0, 'customers.calendar.month.january', 'January'],
|
|
35
|
+
[1, 'customers.calendar.month.february', 'February'],
|
|
36
|
+
[2, 'customers.calendar.month.march', 'March'],
|
|
37
|
+
[3, 'customers.calendar.month.april', 'April'],
|
|
38
|
+
[4, 'customers.calendar.month.may', 'May'],
|
|
39
|
+
[5, 'customers.calendar.month.june', 'June'],
|
|
40
|
+
[6, 'customers.calendar.month.july', 'July'],
|
|
41
|
+
[7, 'customers.calendar.month.august', 'August'],
|
|
42
|
+
[8, 'customers.calendar.month.september', 'September'],
|
|
43
|
+
[9, 'customers.calendar.month.october', 'October'],
|
|
44
|
+
[10, 'customers.calendar.month.november', 'November'],
|
|
45
|
+
[11, 'customers.calendar.month.december', 'December'],
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
function startOfDay(date: Date): Date {
|
|
49
|
+
const next = new Date(date)
|
|
50
|
+
next.setHours(0, 0, 0, 0)
|
|
51
|
+
return next
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function endOfDay(date: Date): Date {
|
|
55
|
+
const next = new Date(date)
|
|
56
|
+
next.setHours(23, 59, 59, 999)
|
|
57
|
+
return next
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isSameDay(a: Date, b: Date): boolean {
|
|
61
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isWeekend(date: Date): boolean {
|
|
65
|
+
const day = date.getDay()
|
|
66
|
+
return day === 0 || day === 6
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function addDays(date: Date, delta: number): Date {
|
|
70
|
+
const next = new Date(date)
|
|
71
|
+
next.setDate(date.getDate() + delta)
|
|
72
|
+
return next
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildVisibleDays(anchor: Date): Date[] {
|
|
76
|
+
const start = startOfDay(anchor)
|
|
77
|
+
return Array.from({ length: VISIBLE_DAYS }, (_, index) => addDays(start, index))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Anchor the visible window so that the given focal date lands at the center slot
|
|
81
|
+
// (position 2 out of 5). Matches Figma 784:809 where the selected day is centered.
|
|
82
|
+
function anchorCenteredOn(focalDate: Date): Date {
|
|
83
|
+
return startOfDay(addDays(focalDate, -Math.floor(VISIBLE_DAYS / 2)))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type SlotState = 'empty' | 'partial' | 'full' | 'conflict'
|
|
87
|
+
|
|
88
|
+
type DayBusyness = {
|
|
89
|
+
totalMinutes: number
|
|
90
|
+
eventCount: number
|
|
91
|
+
slots: SlotState[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function emptyBusyness(): DayBusyness {
|
|
95
|
+
return {
|
|
96
|
+
totalMinutes: 0,
|
|
97
|
+
eventCount: 0,
|
|
98
|
+
slots: Array<SlotState>(BUSYNESS_SLOTS).fill('empty'),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function computeDayBusyness(events: InteractionSummary[], day: Date): DayBusyness {
|
|
103
|
+
if (events.length === 0) return emptyBusyness()
|
|
104
|
+
const dayStart = startOfDay(day).getTime()
|
|
105
|
+
const slotMs = ((SLOT_END_HOUR - SLOT_START_HOUR) * 60 * 60 * 1000) / BUSYNESS_SLOTS
|
|
106
|
+
const slotMinutes = slotMs / 60000
|
|
107
|
+
const slotCounts: number[] = Array(BUSYNESS_SLOTS).fill(0)
|
|
108
|
+
const slotMinutesUsed: number[] = Array(BUSYNESS_SLOTS).fill(0)
|
|
109
|
+
let totalMinutes = 0
|
|
110
|
+
let eventCount = 0
|
|
111
|
+
|
|
112
|
+
for (const event of events) {
|
|
113
|
+
const startIso = event.scheduledAt ?? event.occurredAt ?? event.createdAt
|
|
114
|
+
if (!startIso) continue
|
|
115
|
+
const start = new Date(startIso)
|
|
116
|
+
if (Number.isNaN(start.getTime())) continue
|
|
117
|
+
if (!isSameDay(start, day)) continue
|
|
118
|
+
eventCount += 1
|
|
119
|
+
const durationMinutes = typeof event.duration === 'number' && event.duration > 0 ? event.duration : 30
|
|
120
|
+
totalMinutes += durationMinutes
|
|
121
|
+
const eventStartMs = start.getTime()
|
|
122
|
+
const eventEndMs = eventStartMs + durationMinutes * 60000
|
|
123
|
+
const slotsStartMs = dayStart + SLOT_START_HOUR * 60 * 60 * 1000
|
|
124
|
+
for (let slot = 0; slot < BUSYNESS_SLOTS; slot += 1) {
|
|
125
|
+
const slotStart = slotsStartMs + slot * slotMs
|
|
126
|
+
const slotEnd = slotStart + slotMs
|
|
127
|
+
const overlapStart = Math.max(slotStart, eventStartMs)
|
|
128
|
+
const overlapEnd = Math.min(slotEnd, eventEndMs)
|
|
129
|
+
const overlapMinutes = Math.max(0, (overlapEnd - overlapStart) / 60000)
|
|
130
|
+
if (overlapMinutes <= 0) continue
|
|
131
|
+
slotCounts[slot] += 1
|
|
132
|
+
slotMinutesUsed[slot] += overlapMinutes
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const slots: SlotState[] = slotCounts.map((count, index) => {
|
|
137
|
+
if (count === 0) return 'empty'
|
|
138
|
+
if (count > 1) return 'conflict'
|
|
139
|
+
const used = slotMinutesUsed[index]
|
|
140
|
+
if (used >= slotMinutes * 0.5) return 'full'
|
|
141
|
+
return 'partial'
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
return { totalMinutes, eventCount, slots }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function formatBusyLabel(busy: DayBusyness, t: TranslateFn): string {
|
|
148
|
+
if (busy.eventCount === 0) return ''
|
|
149
|
+
// Match Figma 784:809 label format: "Xm" when under an hour, "Xh" otherwise.
|
|
150
|
+
// Mixed "Xh Ym" overflows the 101px card and is not part of the visual spec.
|
|
151
|
+
const durationLabel = busy.totalMinutes < 60
|
|
152
|
+
? t('customers.activities.calendar.minutesShort', '{minutes}m', { minutes: Math.max(Math.round(busy.totalMinutes), 1) })
|
|
153
|
+
: t('customers.activities.calendar.hoursShort', '{hours}h', { hours: Math.floor(busy.totalMinutes / 60) })
|
|
154
|
+
return t('customers.activities.calendar.eventsSummary', '{count} {countLabel} · {duration}', {
|
|
155
|
+
count: busy.eventCount,
|
|
156
|
+
countLabel: busy.eventCount === 1
|
|
157
|
+
? t('customers.activities.calendar.eventSingular', 'event')
|
|
158
|
+
: t('customers.activities.calendar.eventPlural', 'events'),
|
|
159
|
+
duration: durationLabel,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function formatMonthLabel(date: Date, t: TranslateFn): string {
|
|
164
|
+
const monthEntry = MONTH_KEYS.find(([index]) => index === date.getMonth())
|
|
165
|
+
const monthName = monthEntry ? t(monthEntry[1], monthEntry[2]) : ''
|
|
166
|
+
return t('customers.activities.calendar.monthYear', '{month} {year}', { month: monthName, year: date.getFullYear() })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function formatDayLabel(date: Date, t: TranslateFn): string {
|
|
170
|
+
const entry = DAY_LABEL_KEYS.find(([index]) => index === date.getDay())
|
|
171
|
+
return entry ? t(entry[1], entry[2]) : ''
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function ActivitiesDayStrip({ entityId, selectedDate, onSelectDate, refreshKey = 0 }: ActivitiesDayStripProps) {
|
|
175
|
+
const t = useT()
|
|
176
|
+
const [anchor, setAnchor] = React.useState<Date>(() => anchorCenteredOn(selectedDate))
|
|
177
|
+
const [events, setEvents] = React.useState<InteractionSummary[]>([])
|
|
178
|
+
|
|
179
|
+
React.useEffect(() => {
|
|
180
|
+
setAnchor((current) => {
|
|
181
|
+
const days = buildVisibleDays(current)
|
|
182
|
+
const visible = days.some((day) => isSameDay(day, selectedDate))
|
|
183
|
+
if (visible) return current
|
|
184
|
+
return anchorCenteredOn(selectedDate)
|
|
185
|
+
})
|
|
186
|
+
}, [selectedDate])
|
|
187
|
+
|
|
188
|
+
const visibleDays = React.useMemo(() => buildVisibleDays(anchor), [anchor])
|
|
189
|
+
const headerLabel = React.useMemo(() => formatMonthLabel(visibleDays[0], t), [visibleDays, t])
|
|
190
|
+
|
|
191
|
+
React.useEffect(() => {
|
|
192
|
+
if (!entityId || visibleDays.length === 0) return
|
|
193
|
+
const controller = new AbortController()
|
|
194
|
+
const fromIso = startOfDay(visibleDays[0]).toISOString()
|
|
195
|
+
const toIso = endOfDay(visibleDays[visibleDays.length - 1]).toISOString()
|
|
196
|
+
const params = new URLSearchParams({
|
|
197
|
+
entityId,
|
|
198
|
+
from: fromIso,
|
|
199
|
+
to: toIso,
|
|
200
|
+
limit: '100',
|
|
201
|
+
sortField: 'scheduledAt',
|
|
202
|
+
sortDir: 'asc',
|
|
203
|
+
excludeInteractionType: 'task',
|
|
204
|
+
})
|
|
205
|
+
void (async () => {
|
|
206
|
+
try {
|
|
207
|
+
const payload = await readApiResultOrThrow<{ items?: InteractionSummary[] }>(
|
|
208
|
+
`/api/customers/interactions?${params.toString()}`,
|
|
209
|
+
{ signal: controller.signal },
|
|
210
|
+
)
|
|
211
|
+
setEvents(Array.isArray(payload?.items) ? payload.items : [])
|
|
212
|
+
} catch (err) {
|
|
213
|
+
if ((err as { name?: string } | null)?.name !== 'AbortError') {
|
|
214
|
+
console.warn('[ActivitiesDayStrip] failed to load interactions', err)
|
|
215
|
+
}
|
|
216
|
+
setEvents([])
|
|
217
|
+
}
|
|
218
|
+
})()
|
|
219
|
+
return () => controller.abort()
|
|
220
|
+
}, [entityId, visibleDays, refreshKey])
|
|
221
|
+
|
|
222
|
+
const todayDate = React.useMemo(() => startOfDay(new Date()), [])
|
|
223
|
+
|
|
224
|
+
const handlePrev = React.useCallback(() => {
|
|
225
|
+
setAnchor((current) => addDays(current, -VISIBLE_DAYS))
|
|
226
|
+
}, [])
|
|
227
|
+
const handleNext = React.useCallback(() => {
|
|
228
|
+
setAnchor((current) => addDays(current, VISIBLE_DAYS))
|
|
229
|
+
}, [])
|
|
230
|
+
const handleHeaderPrev = React.useCallback(() => {
|
|
231
|
+
setAnchor((current) => {
|
|
232
|
+
const next = new Date(current)
|
|
233
|
+
next.setMonth(current.getMonth() - 1)
|
|
234
|
+
return startOfDay(next)
|
|
235
|
+
})
|
|
236
|
+
}, [])
|
|
237
|
+
const handleHeaderNext = React.useCallback(() => {
|
|
238
|
+
setAnchor((current) => {
|
|
239
|
+
const next = new Date(current)
|
|
240
|
+
next.setMonth(current.getMonth() + 1)
|
|
241
|
+
return startOfDay(next)
|
|
242
|
+
})
|
|
243
|
+
}, [])
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<div className="flex flex-col gap-2.5 rounded-md px-3.5 py-3 w-full">
|
|
247
|
+
<div className="flex items-center justify-center gap-1.5 rounded-md bg-muted px-1.5 py-1.5">
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
onClick={handleHeaderPrev}
|
|
251
|
+
aria-label={t('customers.activities.calendar.prevMonth', 'Previous month')}
|
|
252
|
+
className="flex size-6 items-center justify-center rounded-md border border-border bg-card shadow-xs hover:bg-accent/40"
|
|
253
|
+
>
|
|
254
|
+
<ChevronLeft className="size-4 text-foreground" />
|
|
255
|
+
</button>
|
|
256
|
+
<span className="flex-1 text-center text-sm font-medium leading-5 text-foreground">{headerLabel}</span>
|
|
257
|
+
<button
|
|
258
|
+
type="button"
|
|
259
|
+
onClick={handleHeaderNext}
|
|
260
|
+
aria-label={t('customers.activities.calendar.nextMonth', 'Next month')}
|
|
261
|
+
className="flex size-6 items-center justify-center rounded-md border border-border bg-card shadow-xs hover:bg-accent/40"
|
|
262
|
+
>
|
|
263
|
+
<ChevronRight className="size-4 text-foreground" />
|
|
264
|
+
</button>
|
|
265
|
+
</div>
|
|
266
|
+
<div className="flex w-full items-center gap-2">
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
onClick={handlePrev}
|
|
270
|
+
aria-label={t('customers.activities.calendar.prevWindow', 'Previous days')}
|
|
271
|
+
className="flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-card shadow-xs hover:bg-accent/40"
|
|
272
|
+
>
|
|
273
|
+
<ChevronLeft className="size-4 text-foreground" />
|
|
274
|
+
</button>
|
|
275
|
+
<div className="flex flex-1 items-stretch justify-center gap-1">
|
|
276
|
+
{visibleDays.map((day) => {
|
|
277
|
+
const busy = computeDayBusyness(events, day)
|
|
278
|
+
const isSelected = isSameDay(day, selectedDate)
|
|
279
|
+
const isToday = isSameDay(day, todayDate)
|
|
280
|
+
const weekend = isWeekend(day)
|
|
281
|
+
const busyLabel = busy.eventCount > 0
|
|
282
|
+
? formatBusyLabel(busy, t)
|
|
283
|
+
: weekend
|
|
284
|
+
? t('customers.activities.calendar.weekend', 'Weekend')
|
|
285
|
+
: ''
|
|
286
|
+
return (
|
|
287
|
+
<DayCard
|
|
288
|
+
key={day.toISOString()}
|
|
289
|
+
day={day}
|
|
290
|
+
isActive={isSelected}
|
|
291
|
+
isToday={isToday}
|
|
292
|
+
busyness={busy}
|
|
293
|
+
label={busyLabel}
|
|
294
|
+
dayName={formatDayLabel(day, t)}
|
|
295
|
+
onSelect={() => onSelectDate(day)}
|
|
296
|
+
/>
|
|
297
|
+
)
|
|
298
|
+
})}
|
|
299
|
+
</div>
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
onClick={handleNext}
|
|
303
|
+
aria-label={t('customers.activities.calendar.nextWindow', 'Next days')}
|
|
304
|
+
className="flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-card shadow-xs hover:bg-accent/40"
|
|
305
|
+
>
|
|
306
|
+
<ChevronRight className="size-4 text-foreground" />
|
|
307
|
+
</button>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
interface DayCardProps {
|
|
314
|
+
day: Date
|
|
315
|
+
isActive: boolean
|
|
316
|
+
isToday: boolean
|
|
317
|
+
busyness: DayBusyness
|
|
318
|
+
label: string
|
|
319
|
+
dayName: string
|
|
320
|
+
onSelect: () => void
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function DayCard({ day, isActive, isToday, busyness, label, dayName, onSelect }: DayCardProps) {
|
|
324
|
+
const dayNumber = String(day.getDate()).padStart(2, '0')
|
|
325
|
+
return (
|
|
326
|
+
<button
|
|
327
|
+
type="button"
|
|
328
|
+
onClick={onSelect}
|
|
329
|
+
aria-pressed={isActive}
|
|
330
|
+
aria-label={`${dayName} ${dayNumber}`}
|
|
331
|
+
className={cn(
|
|
332
|
+
'flex h-[104px] w-[101px] flex-col items-center gap-[6px] overflow-hidden rounded-[10px] border p-[12px] transition-colors',
|
|
333
|
+
isActive
|
|
334
|
+
? 'border-transparent bg-foreground'
|
|
335
|
+
: 'border-border bg-card hover:border-foreground/40',
|
|
336
|
+
)}
|
|
337
|
+
>
|
|
338
|
+
<span className="text-[11px] font-medium leading-none tracking-[0.44px] text-muted-foreground">
|
|
339
|
+
{dayName}
|
|
340
|
+
</span>
|
|
341
|
+
<div className="flex items-center gap-[5px]">
|
|
342
|
+
<span
|
|
343
|
+
className={cn(
|
|
344
|
+
'text-2xl font-semibold leading-7',
|
|
345
|
+
isActive ? 'text-background' : 'text-foreground',
|
|
346
|
+
)}
|
|
347
|
+
>
|
|
348
|
+
{dayNumber}
|
|
349
|
+
</span>
|
|
350
|
+
{isToday ? (
|
|
351
|
+
<span
|
|
352
|
+
className="inline-block size-1.5 rounded-full bg-status-info-icon"
|
|
353
|
+
aria-hidden
|
|
354
|
+
/>
|
|
355
|
+
) : null}
|
|
356
|
+
</div>
|
|
357
|
+
<div className="flex h-4 w-[82px] items-end gap-[1.5px]">
|
|
358
|
+
{busyness.slots.map((state, index) => (
|
|
359
|
+
<BusySlot key={index} state={state} active={isActive} />
|
|
360
|
+
))}
|
|
361
|
+
</div>
|
|
362
|
+
<span className="text-[11px] leading-[14px] font-normal whitespace-nowrap text-muted-foreground">
|
|
363
|
+
{label}
|
|
364
|
+
</span>
|
|
365
|
+
</button>
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function BusySlot({ state, active }: { state: SlotState; active: boolean }) {
|
|
370
|
+
const heightClass = state === 'empty'
|
|
371
|
+
? 'h-0.5'
|
|
372
|
+
: state === 'partial'
|
|
373
|
+
? 'h-2'
|
|
374
|
+
: 'h-3.5'
|
|
375
|
+
let bgClass: string
|
|
376
|
+
if (state === 'conflict') {
|
|
377
|
+
bgClass = 'bg-status-error-icon'
|
|
378
|
+
} else if (active) {
|
|
379
|
+
if (state === 'empty') bgClass = 'bg-background/30'
|
|
380
|
+
else if (state === 'partial') bgClass = 'bg-background/60'
|
|
381
|
+
else bgClass = 'bg-background'
|
|
382
|
+
} else {
|
|
383
|
+
if (state === 'empty') bgClass = 'bg-border'
|
|
384
|
+
else if (state === 'partial') bgClass = 'bg-muted-foreground'
|
|
385
|
+
else bgClass = 'bg-foreground'
|
|
386
|
+
}
|
|
387
|
+
return <div className={cn('w-[7px] shrink-0 rounded-[1.5px]', heightClass, bgClass)} aria-hidden />
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export default ActivitiesDayStrip
|