@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
@@ -9,16 +9,9 @@ const sidebarCustomizeIcon = React.createElement(
9
9
  React.createElement('path', { d: 'M17.5 14v7' }),
10
10
  )
11
11
 
12
- // Page is reachable by any authenticated user — every staff user has
13
- // always been able to customize their PERSONAL sidebar (the variants /
14
- // preferences APIs gate only role-application via `auth.sidebar.manage`).
15
- // Inside the editor, the "Apply to roles" card and role variants picker are
16
- // already conditionally hidden via `canApplyToRoles` (server-checked against
17
- // `auth.sidebar.manage`), so non-admins see only the personal-scope flow,
18
- // matching the pre-PR inline-editor behavior. Restricting the whole page
19
- // to `auth.sidebar.manage` would be a stealth regression for non-admins.
20
12
  export const metadata = {
21
13
  requireAuth: true,
14
+ requireFeatures: ['auth.sidebar.manage'],
22
15
  pageTitle: 'Customize sidebar',
23
16
  pageTitleKey: 'appShell.customizeSidebar',
24
17
  pageGroup: 'Customization',
@@ -42,7 +42,10 @@ import type { EntityId } from '@open-mercato/shared/modules/entities'
42
42
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
43
43
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
44
44
  import { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'
45
- import { withActiveCustomerPersonCompanyLinkFilter } from '../../../lib/personCompanyLinkTable'
45
+ import {
46
+ filterActivePersonCompanyLinks,
47
+ withActiveCustomerPersonCompanyLinkFilter,
48
+ } from '../../../lib/personCompanyLinkTable'
46
49
  import { normalizeCustomerDetailCustomFields } from '../../detailCustomFields'
47
50
 
48
51
  export const metadata = {
@@ -703,15 +706,17 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
703
706
  },
704
707
  'customers.companies.GET',
705
708
  )
706
- const companyLinks = await findWithDecryption(
707
- em,
708
- CustomerPersonCompanyLink,
709
- companyLinkWhere,
710
- {
711
- populate: ['person', 'person.personProfile'],
712
- orderBy: { isPrimary: 'desc', createdAt: 'asc' },
713
- },
714
- peopleDecryptionScope,
709
+ const companyLinks = filterActivePersonCompanyLinks(
710
+ await findWithDecryption(
711
+ em,
712
+ CustomerPersonCompanyLink,
713
+ companyLinkWhere,
714
+ {
715
+ populate: ['person', 'person.personProfile'],
716
+ orderBy: { isPrimary: 'desc', createdAt: 'asc' },
717
+ },
718
+ peopleDecryptionScope,
719
+ ),
715
720
  )
716
721
  companyLinks.forEach((link) => {
717
722
  const entity = typeof link.person === 'string' ? null : link.person
@@ -829,18 +834,23 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
829
834
  })
830
835
  const peopleCount = includePeople
831
836
  ? relatedPeople.length
832
- : await em.count(
833
- CustomerPersonCompanyLink,
834
- await withActiveCustomerPersonCompanyLinkFilter(
837
+ : filterActivePersonCompanyLinks(
838
+ await findWithDecryption(
835
839
  em,
836
- {
837
- company: company.id,
838
- organizationId: company.organizationId,
839
- tenantId: company.tenantId,
840
- },
841
- 'customers.companies.GET',
840
+ CustomerPersonCompanyLink,
841
+ await withActiveCustomerPersonCompanyLinkFilter(
842
+ em,
843
+ {
844
+ company: company.id,
845
+ organizationId: company.organizationId,
846
+ tenantId: company.tenantId,
847
+ },
848
+ 'customers.companies.GET',
849
+ ),
850
+ {},
851
+ { tenantId: company.tenantId, organizationId: company.organizationId },
842
852
  ),
843
- )
853
+ ).length
844
854
  const kpiInteractionRows = canonicalActiveInteractions.length
845
855
  ? canonicalActiveInteractions
846
856
  : await findWithDecryption(
@@ -33,7 +33,10 @@ import {
33
33
  createPagedListResponseSchema,
34
34
  defaultOkResponseSchema,
35
35
  } from '../openapi'
36
- import { withActiveCustomerPersonCompanyLinkFilter } from '../../lib/personCompanyLinkTable'
36
+ import {
37
+ filterActivePersonCompanyLinks,
38
+ withActiveCustomerPersonCompanyLinkFilter,
39
+ } from '../../lib/personCompanyLinkTable'
37
40
  import { normalizeCompanyProfilePayload } from './payload'
38
41
 
39
42
  const rawBodySchema = z.object({}).passthrough()
@@ -217,12 +220,14 @@ const crud = makeCrudRoute({
217
220
  { person: query.excludeLinkedPersonId },
218
221
  'customers.companies.GET',
219
222
  )
220
- const links = await findWithDecryption(
221
- em,
222
- CustomerPersonCompanyLink,
223
- linkWhere,
224
- { populate: ['company'] },
225
- decryptionScope,
223
+ const links = filterActivePersonCompanyLinks(
224
+ await findWithDecryption(
225
+ em,
226
+ CustomerPersonCompanyLink,
227
+ linkWhere,
228
+ { populate: ['company'] },
229
+ decryptionScope,
230
+ ),
226
231
  )
227
232
  links.forEach((link) => {
228
233
  const companyId = link.company?.id
@@ -20,7 +20,10 @@ import {
20
20
  CustomerDeal,
21
21
  CustomerInteraction,
22
22
  } from '../../../../../data/entities'
23
- import { withActiveCustomerPersonCompanyLinkFilter } from '../../../../../lib/personCompanyLinkTable'
23
+ import {
24
+ filterActivePersonCompanyLinks,
25
+ withActiveCustomerPersonCompanyLinkFilter,
26
+ } from '../../../../../lib/personCompanyLinkTable'
24
27
 
25
28
  const paramsSchema = z.object({
26
29
  id: z.string().uuid(),
@@ -187,12 +190,14 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
187
190
  },
188
191
  'customers.people.companiesEnriched.GET',
189
192
  )
190
- const links = await findWithDecryption(
191
- em,
192
- CustomerPersonCompanyLink,
193
- linkWhere,
194
- { populate: ['company'] },
195
- entityScope,
193
+ const links = filterActivePersonCompanyLinks(
194
+ await findWithDecryption(
195
+ em,
196
+ CustomerPersonCompanyLink,
197
+ linkWhere,
198
+ { populate: ['company'] },
199
+ entityScope,
200
+ ),
196
201
  )
197
202
 
198
203
  const companyIds = links.map((link) => (link.company as CustomerEntity).id)
@@ -29,7 +29,10 @@ import {
29
29
  createPagedListResponseSchema,
30
30
  defaultOkResponseSchema,
31
31
  } from '../openapi'
32
- import { withActiveCustomerPersonCompanyLinkFilter } from '../../lib/personCompanyLinkTable'
32
+ import {
33
+ filterActivePersonCompanyLinks,
34
+ withActiveCustomerPersonCompanyLinkFilter,
35
+ } from '../../lib/personCompanyLinkTable'
33
36
  import { normalizeProfilePayload } from './payload'
34
37
 
35
38
  const rawBodySchema = z.object({}).passthrough()
@@ -221,12 +224,14 @@ const crud = makeCrudRoute({
221
224
  { company: query.excludeLinkedCompanyId },
222
225
  'customers.people.GET',
223
226
  )
224
- const links = await findWithDecryption(
225
- em,
226
- CustomerPersonCompanyLink,
227
- linkWhere,
228
- { populate: ['person'] },
229
- decryptionScope,
227
+ const links = filterActivePersonCompanyLinks(
228
+ await findWithDecryption(
229
+ em,
230
+ CustomerPersonCompanyLink,
231
+ linkWhere,
232
+ { populate: ['person'] },
233
+ decryptionScope,
234
+ ),
230
235
  )
231
236
  links.forEach((link) => {
232
237
  const personId = link.person?.id
@@ -219,6 +219,27 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
219
219
  setScheduleDialogOpen(true)
220
220
  }, [])
221
221
 
222
+ const handleAddActivity = React.useCallback((kind: 'meeting' | 'call' | 'task' | 'email') => {
223
+ setScheduleEditData({
224
+ id: '',
225
+ interactionType: kind,
226
+ title: null,
227
+ body: null,
228
+ scheduledAt: null,
229
+ durationMinutes: null,
230
+ location: null,
231
+ allDay: null,
232
+ recurrenceRule: null,
233
+ recurrenceEnd: null,
234
+ participants: null,
235
+ reminderMinutes: null,
236
+ visibility: null,
237
+ linkedEntities: null,
238
+ guestPermissions: null,
239
+ })
240
+ setScheduleDialogOpen(true)
241
+ }, [])
242
+
222
243
  // Injected tabs from UMES
223
244
  const { widgets: injectedTabWidgets } = useInjectionWidgets('detail:customers.company:tabs', {
224
245
  context: injectionContext,
@@ -473,6 +494,7 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
473
494
  plannedActivities={plannedActivities}
474
495
  onActivityCreated={handleActivityCreated}
475
496
  onScheduleRequested={openNewScheduleDialog}
497
+ onAddActivity={handleAddActivity}
476
498
  onMarkDone={handleMarkDone}
477
499
  onEditActivity={handleEditActivity}
478
500
  onCancelActivity={handleCancelActivity}
@@ -23,18 +23,17 @@ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuarde
23
23
  import { createTranslatorWithFallback } from '@open-mercato/shared/lib/i18n/translate'
24
24
 
25
25
  import { ActivitiesSection } from '../../../../components/detail/ActivitiesSection'
26
+ import { ActivitiesCard } from '../../../../components/detail/ActivitiesCard'
27
+ import type { ActivityKind } from '../../../../components/detail/ActivitiesAddNewMenu'
26
28
  import { DealsSection } from '../../../../components/detail/DealsSection'
27
29
  import { TasksSection } from '../../../../components/detail/TasksSection'
28
30
  import type { TagSummary } from '../../../../components/detail/types'
29
- import { InlineActivityComposer } from '../../../../components/detail/InlineActivityComposer'
30
- import { PlannedActivitiesSection } from '../../../../components/detail/PlannedActivitiesSection'
31
31
  import { ScheduleActivityDialog, type ScheduleActivityEditData } from '../../../../components/detail/ScheduleActivityDialog'
32
32
  import { PersonDetailHeader } from '../../../../components/detail/PersonDetailHeader'
33
33
  import { ChangelogTab } from '../../../../components/detail/ChangelogTab'
34
34
  import { PersonDetailTabs, resolveLegacyTab, type PersonTabId } from '../../../../components/detail/PersonDetailTabs'
35
35
  import { PersonCompaniesSection } from '../../../../components/detail/PersonCompaniesSection'
36
36
  import { MobilePersonDetail } from '../../../../components/detail/MobilePersonDetail'
37
- import { useInteractionMutations } from '../../../../components/detail/hooks/useInteractionMutations'
38
37
  import type { TagsSectionController } from '@open-mercato/ui/backend/detail'
39
38
  import {
40
39
  buildPersonEditPayload,
@@ -190,11 +189,26 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
190
189
  [injectionContext, runMutation],
191
190
  )
192
191
 
193
- const { completeInteraction: handleMarkDone, cancelInteraction: handleCancelActivity } = useInteractionMutations({
194
- runMutationWithContext,
195
- onAfterChange: handleActivityCreated,
196
- logContext: 'customers.people-v2',
197
- })
192
+ const handleAddActivity = React.useCallback((kind: ActivityKind) => {
193
+ setScheduleEditData({
194
+ id: '',
195
+ interactionType: kind,
196
+ title: null,
197
+ body: null,
198
+ scheduledAt: null,
199
+ durationMinutes: null,
200
+ location: null,
201
+ allDay: null,
202
+ recurrenceRule: null,
203
+ recurrenceEnd: null,
204
+ participants: null,
205
+ reminderMinutes: null,
206
+ visibility: null,
207
+ linkedEntities: null,
208
+ guestPermissions: null,
209
+ })
210
+ setScheduleDialogOpen(true)
211
+ }, [])
198
212
 
199
213
  const handleEditActivity = React.useCallback((activity: { id: string; interactionType?: string; title?: string | null; body?: string | null; scheduledAt?: string | null; [key: string]: unknown }) => {
200
214
  const raw = activity as Record<string, unknown>
@@ -448,20 +462,13 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
448
462
  if (activeTab === 'activities') {
449
463
  return (
450
464
  <div className="space-y-4">
451
- <InlineActivityComposer
452
- entityType="person"
465
+ <ActivitiesCard
453
466
  entityId={personId}
454
- onActivityCreated={handleActivityCreated}
455
- runGuardedMutation={runMutationWithContext}
456
- onScheduleRequested={() => { setScheduleEditData(null); setScheduleDialogOpen(true) }}
457
- useCanonicalInteractions={useCanonicalInteractions}
458
- />
459
- <PlannedActivitiesSection
460
- activities={plannedActivities}
461
- onComplete={handleMarkDone}
462
- onSchedule={() => { setScheduleEditData(null); setScheduleDialogOpen(true) }}
463
- onEdit={handleEditActivity}
464
- onCancel={handleCancelActivity}
467
+ plannedActivities={plannedActivities}
468
+ refreshKey={activityRefreshKey}
469
+ onAddNew={handleAddActivity}
470
+ onEditActivity={handleEditActivity}
471
+ entityCompanyName={data.company?.displayName ?? data.companies?.[0]?.displayName ?? null}
465
472
  />
466
473
  <ActivitiesSection
467
474
  entityId={personId}
@@ -0,0 +1,67 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { Check, Phone, Mail, Users, CheckSquare } from 'lucide-react'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { Popover, PopoverContent, PopoverTrigger } from '@open-mercato/ui/primitives/popover'
7
+
8
+ export type ActivityKind = 'meeting' | 'call' | 'task' | 'email'
9
+
10
+ interface ActivitiesAddNewMenuProps {
11
+ onSelect: (kind: ActivityKind) => void
12
+ disabled?: boolean
13
+ }
14
+
15
+ const MENU_ITEMS: ReadonlyArray<{ kind: ActivityKind; icon: React.ComponentType<{ className?: string }>; key: string; fallback: string }> = [
16
+ { kind: 'meeting', icon: Users, key: 'customers.activities.add.meeting', fallback: 'New meeting' },
17
+ { kind: 'call', icon: Phone, key: 'customers.activities.add.call', fallback: 'Log call' },
18
+ { kind: 'task', icon: CheckSquare, key: 'customers.activities.add.task', fallback: 'New task' },
19
+ { kind: 'email', icon: Mail, key: 'customers.activities.add.email', fallback: 'Compose email' },
20
+ ]
21
+
22
+ export function ActivitiesAddNewMenu({ onSelect, disabled }: ActivitiesAddNewMenuProps) {
23
+ const t = useT()
24
+ const [open, setOpen] = React.useState(false)
25
+
26
+ const handleSelect = React.useCallback(
27
+ (kind: ActivityKind) => {
28
+ setOpen(false)
29
+ onSelect(kind)
30
+ },
31
+ [onSelect],
32
+ )
33
+
34
+ return (
35
+ <Popover open={open} onOpenChange={setOpen}>
36
+ <PopoverTrigger asChild>
37
+ <button
38
+ type="button"
39
+ disabled={disabled}
40
+ aria-label={t('customers.activities.addNew', 'Add new')}
41
+ className="inline-flex items-center gap-1.5 overflow-hidden rounded-md bg-foreground pl-3 pr-3.5 py-2 text-xs font-semibold text-background transition-colors hover:bg-foreground/90 disabled:opacity-60"
42
+ >
43
+ <Check className="size-3.5" />
44
+ {t('customers.activities.addNew', 'Add new')}
45
+ </button>
46
+ </PopoverTrigger>
47
+ <PopoverContent align="end" className="w-[180px] p-1">
48
+ <ul className="flex flex-col">
49
+ {MENU_ITEMS.map(({ kind, icon: Icon, key, fallback }) => (
50
+ <li key={kind}>
51
+ <button
52
+ type="button"
53
+ onClick={() => handleSelect(kind)}
54
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-accent/40"
55
+ >
56
+ <Icon className="size-4 text-muted-foreground" />
57
+ {t(key, fallback)}
58
+ </button>
59
+ </li>
60
+ ))}
61
+ </ul>
62
+ </PopoverContent>
63
+ </Popover>
64
+ )
65
+ }
66
+
67
+ export default ActivitiesAddNewMenu
@@ -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