@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.
Files changed (81) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/sidebar/preferences/route.js +2 -2
  3. package/dist/modules/auth/api/sidebar/preferences/route.js.map +2 -2
  4. package/dist/modules/auth/api/sidebar/variants/[id]/route.js +2 -2
  5. package/dist/modules/auth/api/sidebar/variants/[id]/route.js.map +2 -2
  6. package/dist/modules/auth/api/sidebar/variants/route.js +1 -1
  7. package/dist/modules/auth/api/sidebar/variants/route.js.map +2 -2
  8. package/dist/modules/auth/backend/sidebar-customization/page.meta.js +1 -0
  9. package/dist/modules/auth/backend/sidebar-customization/page.meta.js.map +2 -2
  10. package/dist/modules/customers/api/companies/[id]/route.js +30 -20
  11. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  12. package/dist/modules/customers/api/companies/route.js +12 -7
  13. package/dist/modules/customers/api/companies/route.js.map +2 -2
  14. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js +12 -7
  15. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js.map +2 -2
  16. package/dist/modules/customers/api/people/route.js +12 -7
  17. package/dist/modules/customers/api/people/route.js.map +2 -2
  18. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +21 -0
  19. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  20. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +27 -30
  21. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  22. package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js +56 -0
  23. package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js.map +7 -0
  24. package/dist/modules/customers/components/detail/ActivitiesCard.js +175 -0
  25. package/dist/modules/customers/components/detail/ActivitiesCard.js.map +7 -0
  26. package/dist/modules/customers/components/detail/ActivitiesDayStrip.js +324 -0
  27. package/dist/modules/customers/components/detail/ActivitiesDayStrip.js.map +7 -0
  28. package/dist/modules/customers/components/detail/ActivitiesSection.js +62 -13
  29. package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
  30. package/dist/modules/customers/components/detail/ActivityLogTab.js +14 -23
  31. package/dist/modules/customers/components/detail/ActivityLogTab.js.map +2 -2
  32. package/dist/modules/customers/components/detail/ActivityTimeline.js +13 -13
  33. package/dist/modules/customers/components/detail/ActivityTimeline.js.map +2 -2
  34. package/dist/modules/customers/components/detail/ActivityTimelineFilters.js +35 -22
  35. package/dist/modules/customers/components/detail/ActivityTimelineFilters.js.map +2 -2
  36. package/dist/modules/customers/components/detail/AiActionChips.js +15 -22
  37. package/dist/modules/customers/components/detail/AiActionChips.js.map +2 -2
  38. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +196 -28
  39. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  40. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js +2 -2
  41. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js.map +2 -2
  42. package/dist/modules/customers/components/detail/schedule/FooterFields.js +14 -2
  43. package/dist/modules/customers/components/detail/schedule/FooterFields.js.map +2 -2
  44. package/dist/modules/customers/components/detail/schedule/LinkedEntitiesField.js +9 -2
  45. package/dist/modules/customers/components/detail/schedule/LinkedEntitiesField.js.map +2 -2
  46. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +9 -2
  47. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
  48. package/dist/modules/customers/components/detail/schedule/fieldConfig.js +25 -4
  49. package/dist/modules/customers/components/detail/schedule/fieldConfig.js.map +2 -2
  50. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +20 -3
  51. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
  52. package/package.json +3 -3
  53. package/src/modules/auth/api/sidebar/preferences/route.ts +2 -2
  54. package/src/modules/auth/api/sidebar/variants/[id]/route.ts +2 -2
  55. package/src/modules/auth/api/sidebar/variants/route.ts +1 -1
  56. package/src/modules/auth/backend/sidebar-customization/page.meta.ts +1 -8
  57. package/src/modules/customers/api/companies/[id]/route.ts +30 -20
  58. package/src/modules/customers/api/companies/route.ts +12 -7
  59. package/src/modules/customers/api/people/[id]/companies/enriched/route.ts +12 -7
  60. package/src/modules/customers/api/people/route.ts +12 -7
  61. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +22 -0
  62. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +28 -21
  63. package/src/modules/customers/components/detail/ActivitiesAddNewMenu.tsx +67 -0
  64. package/src/modules/customers/components/detail/ActivitiesCard.tsx +231 -0
  65. package/src/modules/customers/components/detail/ActivitiesDayStrip.tsx +390 -0
  66. package/src/modules/customers/components/detail/ActivitiesSection.tsx +91 -40
  67. package/src/modules/customers/components/detail/ActivityLogTab.tsx +25 -23
  68. package/src/modules/customers/components/detail/ActivityTimeline.tsx +15 -19
  69. package/src/modules/customers/components/detail/ActivityTimelineFilters.tsx +36 -29
  70. package/src/modules/customers/components/detail/AiActionChips.tsx +17 -23
  71. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +233 -41
  72. package/src/modules/customers/components/detail/schedule/DateTimeFields.tsx +6 -2
  73. package/src/modules/customers/components/detail/schedule/FooterFields.tsx +22 -2
  74. package/src/modules/customers/components/detail/schedule/LinkedEntitiesField.tsx +10 -2
  75. package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +10 -2
  76. package/src/modules/customers/components/detail/schedule/fieldConfig.ts +26 -6
  77. package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +32 -3
  78. package/src/modules/customers/i18n/de.json +69 -2
  79. package/src/modules/customers/i18n/en.json +69 -2
  80. package/src/modules/customers/i18n/es.json +69 -2
  81. 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-2xl border border-border/70 bg-card p-5">
274
- <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
275
- <div className="flex items-center gap-2">
276
- <Clock className="size-4 text-muted-foreground" />
277
- <h3 className="text-base font-semibold text-foreground">
278
- {entityName
279
- ? t('customers.timeline.history.title', 'Interaction history with {{name}}', { name: entityName })
280
- : t('customers.timeline.history.titleGeneric', 'Interaction history')}
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
- <div className="mb-4">
286
- <ActivityTimelineFilters
287
- entityId={entityId}
288
- activeTypes={filterTypes}
289
- dateFrom={filterDateFrom}
290
- dateTo={filterDateTo}
291
- onTypesChange={setFilterTypes}
292
- onDateFromChange={setFilterDateFrom}
293
- onDateToChange={setFilterDateTo}
294
- onReset={() => {
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
- </div>
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 && activities.length === 0 ? (
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={activities} onEdit={onEditActivity} />
309
- {activities.length > 0 ? (
310
- <div className="border-t px-5 py-3">
311
- <div className="flex items-center justify-between gap-3">
312
- <span className="text-xs text-muted-foreground">
313
- {t('customers.activities.seeAll', 'See all {count} activities', { count: activities.length })}
314
- </span>
315
- {hasMore ? (
316
- <Button type="button" variant="link" size="sm" onClick={() => setLoadedPages((value) => value + 1)}>
317
- {t('customers.activities.loadMore', 'Load more')}
318
- </Button>
319
- ) : null}
320
- </div>
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 { InlineActivityComposer } from './InlineActivityComposer'
4
- import { PlannedActivitiesSection } from './PlannedActivitiesSection'
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
- onActivityCreated: () => void
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
- onMarkDone: (id: string) => void
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
- onCancelActivity: (id: string) => void
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
- onMarkDone,
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
- <InlineActivityComposer
41
- entityType="company"
50
+ <ActivitiesCard
42
51
  entityId={entityId}
43
- onActivityCreated={onActivityCreated}
44
- runGuardedMutation={runGuardedMutation}
45
- onScheduleRequested={onScheduleRequested}
46
- useCanonicalInteractions={useCanonicalInteractions}
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