@open-mercato/core 0.5.1-develop.2996.ce62fd491c → 0.5.1-develop.3036.f02c281f23
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/auth/api/sidebar/preferences/route.js +2 -2
- package/dist/modules/auth/api/sidebar/preferences/route.js.map +2 -2
- package/dist/modules/auth/api/sidebar/variants/[id]/route.js +2 -2
- package/dist/modules/auth/api/sidebar/variants/[id]/route.js.map +2 -2
- package/dist/modules/auth/api/sidebar/variants/route.js +1 -1
- package/dist/modules/auth/api/sidebar/variants/route.js.map +2 -2
- package/dist/modules/auth/backend/sidebar-customization/page.meta.js +1 -0
- package/dist/modules/auth/backend/sidebar-customization/page.meta.js.map +2 -2
- 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/auth/api/sidebar/preferences/route.ts +2 -2
- package/src/modules/auth/api/sidebar/variants/[id]/route.ts +2 -2
- package/src/modules/auth/api/sidebar/variants/route.ts +1 -1
- package/src/modules/auth/backend/sidebar-customization/page.meta.ts +1 -8
- 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,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
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import { Clock } from 'lucide-react'
|
|
4
|
+
import { Clock, Search } from 'lucide-react'
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
7
7
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
8
8
|
import type { SectionAction, TabEmptyStateConfig } from '@open-mercato/ui/backend/detail'
|
|
9
9
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
10
|
+
import { Kbd } from '@open-mercato/ui/primitives/kbd'
|
|
10
11
|
import { ActivityTimelineFilters } from './ActivityTimelineFilters'
|
|
11
12
|
import { ActivityTimeline } from './ActivityTimeline'
|
|
12
13
|
import type { ActivitySummary, InteractionSummary } from './types'
|
|
@@ -109,10 +110,42 @@ export function ActivitiesSection({
|
|
|
109
110
|
const [filterTypes, setFilterTypes] = React.useState<string[]>([])
|
|
110
111
|
const [filterDateFrom, setFilterDateFrom] = React.useState('')
|
|
111
112
|
const [filterDateTo, setFilterDateTo] = React.useState('')
|
|
113
|
+
const [searchTerm, setSearchTerm] = React.useState('')
|
|
112
114
|
const [activities, setActivities] = React.useState<InteractionSummary[]>([])
|
|
113
115
|
const [loading, setLoading] = React.useState(false)
|
|
114
116
|
const [hasMore, setHasMore] = React.useState(false)
|
|
115
117
|
const [loadedPages, setLoadedPages] = React.useState(1)
|
|
118
|
+
const searchInputRef = React.useRef<HTMLInputElement>(null)
|
|
119
|
+
|
|
120
|
+
React.useEffect(() => {
|
|
121
|
+
if (!entityId) return
|
|
122
|
+
function handleShortcut(event: KeyboardEvent) {
|
|
123
|
+
if ((event.metaKey || event.ctrlKey) && event.key === '1') {
|
|
124
|
+
event.preventDefault()
|
|
125
|
+
searchInputRef.current?.focus()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
window.addEventListener('keydown', handleShortcut)
|
|
129
|
+
return () => window.removeEventListener('keydown', handleShortcut)
|
|
130
|
+
}, [entityId])
|
|
131
|
+
|
|
132
|
+
const visibleActivities = React.useMemo(() => {
|
|
133
|
+
const term = searchTerm.trim().toLowerCase()
|
|
134
|
+
if (!term) return activities
|
|
135
|
+
return activities.filter((activity) => {
|
|
136
|
+
const haystack = [
|
|
137
|
+
activity.title,
|
|
138
|
+
activity.body,
|
|
139
|
+
activity.authorName,
|
|
140
|
+
activity.dealTitle,
|
|
141
|
+
activity.interactionType,
|
|
142
|
+
]
|
|
143
|
+
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
|
144
|
+
.join(' ')
|
|
145
|
+
.toLowerCase()
|
|
146
|
+
return haystack.includes(term)
|
|
147
|
+
})
|
|
148
|
+
}, [activities, searchTerm])
|
|
116
149
|
|
|
117
150
|
React.useEffect(() => {
|
|
118
151
|
onActionChange?.(null)
|
|
@@ -269,55 +302,73 @@ export function ActivitiesSection({
|
|
|
269
302
|
return () => controller.abort()
|
|
270
303
|
}, [activities])
|
|
271
304
|
|
|
305
|
+
const totalCount = activities.length
|
|
306
|
+
const visibleCount = visibleActivities.length
|
|
307
|
+
|
|
272
308
|
return (
|
|
273
|
-
<div className="rounded-
|
|
274
|
-
<div className="
|
|
275
|
-
<
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
{entityName
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
</h3>
|
|
282
|
-
</div>
|
|
309
|
+
<div className="flex flex-col gap-3.5 rounded-[10px] border border-border bg-card pt-4 pb-[18px] px-[18px]">
|
|
310
|
+
<div className="flex items-center gap-2">
|
|
311
|
+
<Clock className="size-[15px] text-muted-foreground" />
|
|
312
|
+
<h3 className="text-[13px] font-semibold text-foreground">
|
|
313
|
+
{entityName
|
|
314
|
+
? t('customers.timeline.history.title', 'Interaction history with {{name}}', { name: entityName })
|
|
315
|
+
: t('customers.timeline.history.titleGeneric', 'Interaction history')}
|
|
316
|
+
</h3>
|
|
283
317
|
</div>
|
|
284
318
|
|
|
285
|
-
<
|
|
286
|
-
<
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
setFilterTypes([])
|
|
296
|
-
setFilterDateFrom('')
|
|
297
|
-
setFilterDateTo('')
|
|
298
|
-
}}
|
|
319
|
+
<label className="relative flex items-center">
|
|
320
|
+
<Search className="pointer-events-none absolute left-2.5 size-5 text-muted-foreground" aria-hidden />
|
|
321
|
+
<input
|
|
322
|
+
ref={searchInputRef}
|
|
323
|
+
type="search"
|
|
324
|
+
value={searchTerm}
|
|
325
|
+
onChange={(event) => setSearchTerm(event.target.value)}
|
|
326
|
+
placeholder={t('customers.timeline.history.searchPlaceholder', 'Search...')}
|
|
327
|
+
aria-label={t('customers.timeline.history.searchAriaLabel', 'Search interaction history')}
|
|
328
|
+
className="h-9 w-full rounded-[10px] border border-border bg-card pl-9 pr-14 text-sm text-foreground shadow-xs placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden"
|
|
299
329
|
/>
|
|
300
|
-
|
|
330
|
+
<Kbd className="pointer-events-none absolute right-2 hidden text-[11px] uppercase tracking-[0.48px] sm:inline-flex">
|
|
331
|
+
⌘1
|
|
332
|
+
</Kbd>
|
|
333
|
+
</label>
|
|
334
|
+
|
|
335
|
+
<ActivityTimelineFilters
|
|
336
|
+
entityId={entityId}
|
|
337
|
+
activeTypes={filterTypes}
|
|
338
|
+
dateFrom={filterDateFrom}
|
|
339
|
+
dateTo={filterDateTo}
|
|
340
|
+
onTypesChange={setFilterTypes}
|
|
341
|
+
onDateFromChange={setFilterDateFrom}
|
|
342
|
+
onDateToChange={setFilterDateTo}
|
|
343
|
+
onReset={() => {
|
|
344
|
+
setFilterTypes([])
|
|
345
|
+
setFilterDateFrom('')
|
|
346
|
+
setFilterDateTo('')
|
|
347
|
+
}}
|
|
348
|
+
/>
|
|
301
349
|
|
|
302
|
-
{loading &&
|
|
350
|
+
{loading && totalCount === 0 ? (
|
|
303
351
|
<div className="rounded-lg border border-dashed border-border/70 px-4 py-8 text-sm text-muted-foreground">
|
|
304
352
|
{t('customers.people.detail.activities.loading', 'Loading activities…')}
|
|
305
353
|
</div>
|
|
306
354
|
) : (
|
|
307
355
|
<>
|
|
308
|
-
<ActivityTimeline activities={
|
|
309
|
-
{
|
|
310
|
-
<div className="border-t
|
|
311
|
-
<
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
356
|
+
<ActivityTimeline activities={visibleActivities} onEdit={onEditActivity} />
|
|
357
|
+
{totalCount > 0 ? (
|
|
358
|
+
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3">
|
|
359
|
+
<span className="text-xs text-muted-foreground">
|
|
360
|
+
{searchTerm.trim()
|
|
361
|
+
? t('customers.activities.seeMatching', 'Showing {visible} of {total} activities', {
|
|
362
|
+
visible: visibleCount,
|
|
363
|
+
total: totalCount,
|
|
364
|
+
})
|
|
365
|
+
: t('customers.activities.seeAll', 'See all {count} activities', { count: totalCount })}
|
|
366
|
+
</span>
|
|
367
|
+
{hasMore ? (
|
|
368
|
+
<Button type="button" variant="link" size="sm" onClick={() => setLoadedPages((value) => value + 1)}>
|
|
369
|
+
{t('customers.activities.loadMore', 'Load more')}
|
|
370
|
+
</Button>
|
|
371
|
+
) : null}
|
|
321
372
|
</div>
|
|
322
373
|
) : null}
|
|
323
374
|
</>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { ActivitiesCard } from './ActivitiesCard'
|
|
4
|
+
import type { ActivityKind } from './ActivitiesAddNewMenu'
|
|
5
5
|
import { ActivityHistorySection } from './ActivityHistorySection'
|
|
6
6
|
import type { InteractionSummary } from './types'
|
|
7
7
|
|
|
@@ -13,45 +13,47 @@ type GuardedMutationRunner = <T,>(
|
|
|
13
13
|
type ActivityLogTabProps = {
|
|
14
14
|
entityId: string
|
|
15
15
|
plannedActivities: InteractionSummary[]
|
|
16
|
-
|
|
16
|
+
/** @deprecated No longer used after the ActivitiesCard refactor. Kept optional for callers; remove after one minor cycle. */
|
|
17
|
+
onActivityCreated?: () => void
|
|
17
18
|
onScheduleRequested: () => void
|
|
18
|
-
|
|
19
|
+
onAddActivity?: (kind: ActivityKind) => void
|
|
20
|
+
/** @deprecated No longer used after the ActivitiesCard refactor. Kept optional for callers; remove after one minor cycle. */
|
|
21
|
+
onMarkDone?: (id: string) => void
|
|
19
22
|
onEditActivity: (activity: InteractionSummary) => void
|
|
20
|
-
|
|
23
|
+
/** @deprecated No longer used after the ActivitiesCard refactor. Kept optional for callers; remove after one minor cycle. */
|
|
24
|
+
onCancelActivity?: (id: string) => void
|
|
25
|
+
/** @deprecated No longer used after the ActivitiesCard refactor. Kept optional for callers; remove after one minor cycle. */
|
|
21
26
|
runGuardedMutation?: GuardedMutationRunner
|
|
22
27
|
refreshKey?: number
|
|
23
28
|
useCanonicalInteractions?: boolean
|
|
29
|
+
/** Optional parent-entity company name; surfaces in planned event subtitles when no deal is set. */
|
|
30
|
+
entityCompanyName?: string | null
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
export function ActivityLogTab({
|
|
27
34
|
entityId,
|
|
28
35
|
plannedActivities,
|
|
29
|
-
onActivityCreated,
|
|
30
36
|
onScheduleRequested,
|
|
31
|
-
|
|
37
|
+
onAddActivity,
|
|
32
38
|
onEditActivity,
|
|
33
|
-
onCancelActivity,
|
|
34
|
-
runGuardedMutation,
|
|
35
39
|
refreshKey = 0,
|
|
36
40
|
useCanonicalInteractions = false,
|
|
41
|
+
entityCompanyName,
|
|
37
42
|
}: ActivityLogTabProps) {
|
|
43
|
+
const handleAddNew = (kind: ActivityKind) => {
|
|
44
|
+
if (onAddActivity) onAddActivity(kind)
|
|
45
|
+
else onScheduleRequested()
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
return (
|
|
39
49
|
<div className="space-y-4">
|
|
40
|
-
<
|
|
41
|
-
entityType="company"
|
|
50
|
+
<ActivitiesCard
|
|
42
51
|
entityId={entityId}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
<PlannedActivitiesSection
|
|
50
|
-
activities={plannedActivities}
|
|
51
|
-
onComplete={onMarkDone}
|
|
52
|
-
onSchedule={onScheduleRequested}
|
|
53
|
-
onEdit={onEditActivity}
|
|
54
|
-
onCancel={onCancelActivity}
|
|
52
|
+
plannedActivities={plannedActivities}
|
|
53
|
+
refreshKey={refreshKey}
|
|
54
|
+
onAddNew={handleAddNew}
|
|
55
|
+
onEditActivity={onEditActivity}
|
|
56
|
+
entityCompanyName={entityCompanyName}
|
|
55
57
|
/>
|
|
56
58
|
|
|
57
59
|
<ActivityHistorySection
|