@open-mercato/core 0.6.6-develop.6299.1.29224e2d86 → 0.6.6-develop.6304.1.4cf2b975cb

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 (30) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
  3. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +26 -22
  4. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  5. package/dist/modules/customers/components/detail/CompanyKpiBar.js +8 -7
  6. package/dist/modules/customers/components/detail/CompanyKpiBar.js.map +2 -2
  7. package/dist/modules/customers/components/detail/CompanyPeopleSection.js +6 -5
  8. package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
  9. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +32 -10
  10. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
  11. package/dist/modules/data_sync/components/IntegrationScheduleTab.js +77 -32
  12. package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
  13. package/dist/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.js +6 -0
  14. package/dist/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.js.map +2 -2
  15. package/dist/modules/dictionaries/api/[dictionaryId]/route.js +6 -0
  16. package/dist/modules/dictionaries/api/[dictionaryId]/route.js.map +2 -2
  17. package/dist/modules/messages/api/route.js.map +2 -2
  18. package/package.json +7 -7
  19. package/src/modules/customers/api/people/[id]/route.ts +7 -0
  20. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +29 -22
  21. package/src/modules/customers/components/detail/CompanyKpiBar.tsx +8 -7
  22. package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +6 -5
  23. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +32 -10
  24. package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +77 -33
  25. package/src/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.ts +7 -0
  26. package/src/modules/dictionaries/api/[dictionaryId]/route.ts +7 -0
  27. package/src/modules/messages/api/route.ts +8 -0
  28. package/dist/modules/customers/components/detail/CompanyDashboardTab.js +0 -133
  29. package/dist/modules/customers/components/detail/CompanyDashboardTab.js.map +0 -7
  30. package/src/modules/customers/components/detail/CompanyDashboardTab.tsx +0 -161
@@ -3,6 +3,7 @@
3
3
  import * as React from 'react'
4
4
  import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
5
5
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
6
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
6
7
  import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
7
8
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
8
9
  import { Badge } from '@open-mercato/ui/primitives/badge'
@@ -146,6 +147,9 @@ function buildScheduleEditors(
146
147
  export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
147
148
  const t = useT()
148
149
  const scopeVersion = useOrganizationScopeVersion()
150
+ const { runMutation } = useGuardedMutation<Record<string, unknown>>({
151
+ contextId: 'data_sync.integrationTab',
152
+ })
149
153
  const [option, setOption] = React.useState<SyncOption | null>(null)
150
154
  const [schedules, setSchedules] = React.useState<Record<string, SyncScheduleEditorState>>({})
151
155
  const [isLoading, setIsLoading] = React.useState(true)
@@ -222,18 +226,32 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
222
226
  setRunningKey(scheduleKey)
223
227
  try {
224
228
  const scheduleState = schedules[scheduleKey] ?? buildDefaultScheduleState(entityType)
225
- // optimistic-lock-exempt: starts a new sync run (create), not a concurrent record edit
226
- const call = await apiCall<{ id: string }>('/api/data_sync/run', {
227
- method: 'POST',
228
- headers: { 'content-type': 'application/json' },
229
- body: JSON.stringify({
229
+ const call = await runMutation({
230
+ // optimistic-lock-exempt: starts a new sync run (create), not a concurrent record edit
231
+ operation: () => apiCall<{ id: string }>('/api/data_sync/run', {
232
+ method: 'POST',
233
+ headers: { 'content-type': 'application/json' },
234
+ body: JSON.stringify({
235
+ integrationId: props.integrationId,
236
+ entityType,
237
+ direction,
238
+ fullSync: scheduleState.fullSync,
239
+ batchSize: 100,
240
+ }),
241
+ }, { fallback: null }),
242
+ mutationPayload: {
230
243
  integrationId: props.integrationId,
231
244
  entityType,
232
245
  direction,
233
246
  fullSync: scheduleState.fullSync,
234
247
  batchSize: 100,
235
- }),
236
- }, { fallback: null })
248
+ },
249
+ context: {
250
+ operation: 'create',
251
+ actionId: 'start-sync-run',
252
+ integrationId: props.integrationId,
253
+ },
254
+ })
237
255
 
238
256
  if (!call.ok) {
239
257
  throw new Error((call.result as { error?: string } | null)?.error ?? 'Failed to start sync')
@@ -246,7 +264,7 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
246
264
  } finally {
247
265
  setRunningKey(null)
248
266
  }
249
- }, [option?.canStartRun, props.hasCredentials, props.integrationId, props.isEnabled, schedules, t])
267
+ }, [option?.canStartRun, props.hasCredentials, props.integrationId, props.isEnabled, runMutation, schedules, t])
250
268
 
251
269
  const handleSaveSchedule = React.useCallback(async (entityType: string, direction: 'import' | 'export', scheduleKey: string) => {
252
270
  const scheduleState = schedules[scheduleKey] ?? buildDefaultScheduleState(entityType)
@@ -255,23 +273,40 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
255
273
  // Keyed upsert (POST). When the server resolves an existing row the save
256
274
  // version-checks against this schedule's loaded `updatedAt`; for a brand
257
275
  // new row the header is empty so the create path is unaffected.
258
- const call = await withScopedApiRequestHeaders(
259
- buildOptimisticLockHeader(scheduleState.updatedAt),
260
- () => apiCall<SyncScheduleRecord>('/api/data_sync/schedules', {
261
- method: 'POST',
262
- headers: { 'content-type': 'application/json' },
263
- body: JSON.stringify({
264
- integrationId: props.integrationId,
265
- entityType,
266
- direction,
267
- scheduleType: scheduleState.scheduleType,
268
- scheduleValue: scheduleState.scheduleValue,
269
- timezone: scheduleState.timezone,
270
- fullSync: scheduleState.fullSync,
271
- isEnabled: scheduleState.isEnabled,
272
- }),
273
- }, { fallback: null }),
274
- )
276
+ const call = await runMutation({
277
+ operation: () => withScopedApiRequestHeaders(
278
+ buildOptimisticLockHeader(scheduleState.updatedAt),
279
+ () => apiCall<SyncScheduleRecord>('/api/data_sync/schedules', {
280
+ method: 'POST',
281
+ headers: { 'content-type': 'application/json' },
282
+ body: JSON.stringify({
283
+ integrationId: props.integrationId,
284
+ entityType,
285
+ direction,
286
+ scheduleType: scheduleState.scheduleType,
287
+ scheduleValue: scheduleState.scheduleValue,
288
+ timezone: scheduleState.timezone,
289
+ fullSync: scheduleState.fullSync,
290
+ isEnabled: scheduleState.isEnabled,
291
+ }),
292
+ }, { fallback: null }),
293
+ ),
294
+ mutationPayload: {
295
+ integrationId: props.integrationId,
296
+ entityType,
297
+ direction,
298
+ scheduleType: scheduleState.scheduleType,
299
+ scheduleValue: scheduleState.scheduleValue,
300
+ timezone: scheduleState.timezone,
301
+ fullSync: scheduleState.fullSync,
302
+ isEnabled: scheduleState.isEnabled,
303
+ },
304
+ context: {
305
+ operation: 'update',
306
+ actionId: 'save-sync-schedule',
307
+ integrationId: props.integrationId,
308
+ },
309
+ })
275
310
 
276
311
  if (!call.ok || !call.result) {
277
312
  const conflictError = Object.assign(
@@ -304,7 +339,7 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
304
339
  } finally {
305
340
  setSavingKey(null)
306
341
  }
307
- }, [props.integrationId, schedules, t, updateScheduleEditor])
342
+ }, [props.integrationId, runMutation, schedules, t, updateScheduleEditor])
308
343
 
309
344
  const handleDeleteSchedule = React.useCallback(async (entityType: string, scheduleKey: string) => {
310
345
  const scheduleState = schedules[scheduleKey]
@@ -315,12 +350,21 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
315
350
 
316
351
  setDeletingKey(scheduleKey)
317
352
  try {
318
- const call = await withScopedApiRequestHeaders(
319
- buildOptimisticLockHeader(scheduleState.updatedAt),
320
- () => apiCall(`/api/data_sync/schedules/${encodeURIComponent(scheduleState.id as string)}`, {
321
- method: 'DELETE',
322
- }, { fallback: null }),
323
- )
353
+ const call = await runMutation({
354
+ operation: () => withScopedApiRequestHeaders(
355
+ buildOptimisticLockHeader(scheduleState.updatedAt),
356
+ () => apiCall(`/api/data_sync/schedules/${encodeURIComponent(scheduleState.id as string)}`, {
357
+ method: 'DELETE',
358
+ }, { fallback: null }),
359
+ ),
360
+ mutationPayload: {
361
+ scheduleId: scheduleState.id,
362
+ },
363
+ context: {
364
+ operation: 'delete',
365
+ actionId: 'delete-sync-schedule',
366
+ },
367
+ })
324
368
 
325
369
  if (!call.ok) {
326
370
  throw new Error((call.result as { error?: string } | null)?.error ?? 'Failed to delete schedule')
@@ -337,7 +381,7 @@ export function IntegrationScheduleTab(props: IntegrationScheduleTabProps) {
337
381
  } finally {
338
382
  setDeletingKey(null)
339
383
  }
340
- }, [schedules, t, updateScheduleEditor])
384
+ }, [runMutation, schedules, t, updateScheduleEditor])
341
385
 
342
386
  if (isLoading) {
343
387
  return (
@@ -178,6 +178,13 @@ export async function DELETE(req: Request, ctx: { params?: { dictionaryId?: stri
178
178
  })
179
179
  const dictionary = await loadDictionary(context, dictionaryId)
180
180
  const entry = await loadEntry(context, dictionary, entryId)
181
+ enforceCommandOptimisticLock({
182
+ resourceKind: 'dictionaries.entry',
183
+ resourceId: entry.id,
184
+ current: entry.updatedAt ?? null,
185
+ request: req,
186
+ })
187
+
181
188
  const guardUserId = resolveDictionaryActorId(context.auth)
182
189
  const guardResult = await validateCrudMutationGuard(context.container, {
183
190
  tenantId: context.tenantId,
@@ -236,6 +236,13 @@ export async function DELETE(req: Request, ctx: { params?: { dictionaryId?: stri
236
236
  const { dictionaryId } = paramsSchema.parse({ dictionaryId: ctx.params?.dictionaryId })
237
237
  const dictionary = await loadDictionary(context, dictionaryId)
238
238
 
239
+ enforceCommandOptimisticLock({
240
+ resourceKind: 'dictionaries.dictionary',
241
+ resourceId: dictionary.id,
242
+ current: dictionary.updatedAt ?? null,
243
+ request: req,
244
+ })
245
+
239
246
  const guardUserId = resolveDictionaryActorId(context.auth)
240
247
  const guardResult = await validateCrudMutationGuard(context.container, {
241
248
  tenantId: context.tenantId,
@@ -189,6 +189,14 @@ export async function GET(req: Request) {
189
189
  return q
190
190
  }
191
191
 
192
+ // Audited for #3386 rollout (P3): sort is on m.sent_at (a plain timestamp —
193
+ // not in the messages:message encryption map whose encrypted fields are:
194
+ // subject, body, external_email, external_name, action_data, action_result).
195
+ // The handler already uses the correct two-phase shape: Kysely SQL
196
+ // ORDER BY + LIMIT/OFFSET produces a bounded page of IDs, then
197
+ // findWithDecryption is called only for those IDs — never for the full
198
+ // result set. The #3278 unbounded-decrypt hazard does not apply here.
199
+ // Covered by __tests__/list.test.ts.
192
200
  const countResult = await buildBaseQuery()
193
201
  .select(sql<number>`count(*)`.as('count'))
194
202
  .executeTakeFirst() as { count: string | number } | undefined
@@ -1,133 +0,0 @@
1
- "use client";
2
- import { jsx, jsxs } from "react/jsx-runtime";
3
- import * as React from "react";
4
- import { EyeOff } from "lucide-react";
5
- import { useT } from "@open-mercato/shared/lib/i18n/context";
6
- import { KpiCard } from "@open-mercato/ui/backend/charts/KpiCard";
7
- import { Button } from "@open-mercato/ui/primitives/button";
8
- import { IconButton } from "@open-mercato/ui/primitives/icon-button";
9
- import { InlineActivityComposer } from "./InlineActivityComposer.js";
10
- import { formatCurrency } from "./utils.js";
11
- import { computeHealthScore } from "./healthScoreUtils.js";
12
- import {
13
- readJsonFromLocalStorage,
14
- writeJsonToLocalStorage,
15
- removeLocalStorageKey
16
- } from "@open-mercato/shared/lib/browser/safeLocalStorage";
17
- import {
18
- sumActiveDeals,
19
- getActiveDeals,
20
- getOpenTasks,
21
- getUpcomingMeetings,
22
- getRecentActivity,
23
- computeActivityTrend,
24
- computeDealTrend
25
- } from "./dashboard/helpers.js";
26
- import { UpcomingMeetingsWidget } from "./dashboard/UpcomingMeetingsWidget.js";
27
- import { RelationshipHealthWidget } from "./dashboard/RelationshipHealthWidget.js";
28
- import { ActiveDealWidget } from "./dashboard/ActiveDealWidget.js";
29
- import { OpenTasksWidget } from "./dashboard/OpenTasksWidget.js";
30
- import { RecentActivityWidget } from "./dashboard/RecentActivityWidget.js";
31
- function CompanyDashboardTab({ data, companyId, onTabChange, onActivityCreated, onScheduleRequested, runGuardedMutation, useCanonicalInteractions }) {
32
- const t = useT();
33
- const activeDeals = React.useMemo(() => getActiveDeals(data.deals), [data.deals]);
34
- const activeDealsValue = React.useMemo(() => sumActiveDeals(data.deals), [data.deals]);
35
- const openTasks = React.useMemo(() => getOpenTasks(data.todos), [data.todos]);
36
- const upcomingMeetings = React.useMemo(() => getUpcomingMeetings(data.interactions), [data.interactions]);
37
- const recentActivity = React.useMemo(() => getRecentActivity(data.interactions), [data.interactions]);
38
- const dealCurrency = activeDeals[0]?.valueCurrency ?? data.deals[0]?.valueCurrency ?? "PLN";
39
- const activityTrend = React.useMemo(() => computeActivityTrend(data.interactions), [data.interactions]);
40
- const dealTrend = React.useMemo(() => computeDealTrend(data.deals), [data.deals]);
41
- const healthScore = React.useMemo(() => computeHealthScore(data.interactions), [data.interactions]);
42
- const ltvValue = React.useMemo(() => {
43
- const wonDeals = data.deals.filter((d) => d.status === "won");
44
- if (wonDeals.length === 0) return null;
45
- return wonDeals.reduce((sum, d) => {
46
- const amt = typeof d.valueAmount === "number" ? d.valueAmount : parseFloat(String(d.valueAmount ?? "0"));
47
- return sum + (Number.isFinite(amt) ? amt : 0);
48
- }, 0);
49
- }, [data.deals]);
50
- const clientTenureYears = React.useMemo(() => {
51
- const allDates = data.interactions.map((i) => i.occurredAt ?? i.scheduledAt ?? i.createdAt).filter(Boolean).map((d) => new Date(d).getTime());
52
- if (allDates.length === 0) return null;
53
- const earliest = Math.min(...allDates);
54
- return Math.floor((Date.now() - earliest) / (365.25 * 864e5));
55
- }, [data.interactions]);
56
- const [hiddenTiles, setHiddenTiles] = React.useState(
57
- () => new Set(readJsonFromLocalStorage("om:dashboard-hidden-tiles", []))
58
- );
59
- const toggleTile = React.useCallback((tileId) => {
60
- setHiddenTiles((prev) => {
61
- const next = new Set(prev);
62
- if (next.has(tileId)) next.delete(tileId);
63
- else next.add(tileId);
64
- writeJsonToLocalStorage("om:dashboard-hidden-tiles", [...next]);
65
- return next;
66
- });
67
- }, []);
68
- const kpiTiles = [
69
- { id: "activeDeals", title: t("customers.companies.dashboard.kpi.activeDeals", "ACTIVE DEALS"), value: activeDealsValue, trend: dealTrend, formatValue: (v) => formatCurrency(v, dealCurrency), comparisonLabel: `${activeDeals.length} ${activeDeals.length === 1 ? "pipeline" : "pipelines"}` },
70
- { id: "activities", title: t("customers.companies.dashboard.kpi.activities", "ACTIVITIES"), value: data.interactions.length, trend: activityTrend, comparisonLabel: t("customers.companies.dashboard.kpi.last12months", "last 12 months") },
71
- { id: "ltv", title: t("customers.companies.dashboard.kpi.ltv", "CUSTOMER VALUE (LTV)"), value: ltvValue, formatValue: ltvValue !== null ? (v) => formatCurrency(v, dealCurrency) : void 0, comparisonLabel: ltvValue !== null ? t("customers.companies.dashboard.kpi.wonDeals", "won deals total") : t("customers.companies.dashboard.kpi.noWonDeals", "No won deals") },
72
- { id: "clientSince", title: t("customers.companies.dashboard.kpi.clientSince", "CLIENT SINCE"), value: clientTenureYears, formatValue: clientTenureYears !== null ? (v) => v < 1 ? `< 1 ${t("customers.companies.dashboard.kpi.year", "year")}` : `${v} ${v === 1 ? t("customers.companies.dashboard.kpi.year", "year") : t("customers.companies.dashboard.kpi.years", "years")}` : void 0, comparisonLabel: clientTenureYears !== null ? `${data.deals.filter((d) => d.status === "won").length} ${t("customers.companies.dashboard.kpi.completedDeals", "completed deals")}` : t("customers.companies.dashboard.kpi.noInteractions", "No interactions yet") }
73
- ];
74
- return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
75
- /* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 gap-4 lg:grid-cols-4", children: kpiTiles.filter((tile) => !hiddenTiles.has(tile.id)).map((tile) => /* @__PURE__ */ jsxs("div", { className: "relative group", children: [
76
- /* @__PURE__ */ jsx(
77
- KpiCard,
78
- {
79
- title: tile.title,
80
- value: tile.value,
81
- trend: tile.trend,
82
- formatValue: tile.formatValue,
83
- comparisonLabel: tile.comparisonLabel
84
- }
85
- ),
86
- /* @__PURE__ */ jsx(
87
- IconButton,
88
- {
89
- type: "button",
90
- variant: "ghost",
91
- size: "sm",
92
- onClick: () => toggleTile(tile.id),
93
- className: "h-auto absolute top-2 right-2 opacity-0 group-hover:opacity-60 transition-opacity text-muted-foreground hover:text-foreground p-0",
94
- "aria-label": t("customers.companies.dashboard.hideTile", "Hide tile"),
95
- children: /* @__PURE__ */ jsx(EyeOff, { className: "size-3.5" })
96
- }
97
- )
98
- ] }, tile.id)) }),
99
- hiddenTiles.size > 0 && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
100
- /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: t("customers.companies.dashboard.hiddenTiles", "{{count}} tiles hidden", { count: hiddenTiles.size }) }),
101
- /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-auto text-xs px-1.5 py-0.5", onClick: () => {
102
- setHiddenTiles(/* @__PURE__ */ new Set());
103
- removeLocalStorageKey("om:dashboard-hidden-tiles");
104
- }, children: t("customers.companies.dashboard.showAll", "Show all") })
105
- ] }),
106
- /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-1 gap-6 lg:grid-cols-[1fr_380px]", children: [
107
- /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
108
- /* @__PURE__ */ jsx(UpcomingMeetingsWidget, { meetings: upcomingMeetings, t }),
109
- /* @__PURE__ */ jsx(OpenTasksWidget, { tasks: openTasks, t, onViewAll: () => onTabChange("activity-log"), currentUserId: null })
110
- ] }),
111
- /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
112
- /* @__PURE__ */ jsx(RelationshipHealthWidget, { health: healthScore, t }),
113
- /* @__PURE__ */ jsx(ActiveDealWidget, { deals: activeDeals, t })
114
- ] })
115
- ] }),
116
- /* @__PURE__ */ jsx(
117
- InlineActivityComposer,
118
- {
119
- entityType: "company",
120
- entityId: companyId,
121
- onActivityCreated,
122
- runGuardedMutation,
123
- onScheduleRequested,
124
- useCanonicalInteractions
125
- }
126
- ),
127
- /* @__PURE__ */ jsx(RecentActivityWidget, { interactions: recentActivity, t })
128
- ] });
129
- }
130
- export {
131
- CompanyDashboardTab
132
- };
133
- //# sourceMappingURL=CompanyDashboardTab.js.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../../../src/modules/customers/components/detail/CompanyDashboardTab.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { EyeOff } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { KpiCard, type KpiTrend } from '@open-mercato/ui/backend/charts/KpiCard'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { IconButton } from '@open-mercato/ui/primitives/icon-button'\nimport { InlineActivityComposer } from './InlineActivityComposer'\nimport type { CompanyOverview } from '../formConfig'\nimport { formatCurrency } from './utils'\nimport { computeHealthScore } from './healthScoreUtils'\nimport {\n readJsonFromLocalStorage,\n writeJsonToLocalStorage,\n removeLocalStorageKey,\n} from '@open-mercato/shared/lib/browser/safeLocalStorage'\nimport {\n sumActiveDeals,\n getActiveDeals,\n getOpenTasks,\n getUpcomingMeetings,\n getRecentActivity,\n computeActivityTrend,\n computeDealTrend,\n} from './dashboard/helpers'\nimport { UpcomingMeetingsWidget } from './dashboard/UpcomingMeetingsWidget'\nimport { RelationshipHealthWidget } from './dashboard/RelationshipHealthWidget'\nimport { ActiveDealWidget } from './dashboard/ActiveDealWidget'\nimport { OpenTasksWidget } from './dashboard/OpenTasksWidget'\nimport { RecentActivityWidget } from './dashboard/RecentActivityWidget'\n\ntype GuardedMutationRunner = <T>(\n operation: () => Promise<T>,\n mutationPayload?: Record<string, unknown>,\n) => Promise<T>\n\ntype CompanyDashboardTabProps = {\n data: CompanyOverview\n companyId: string\n onTabChange: (tab: string) => void\n onActivityCreated?: () => void\n onScheduleRequested?: () => void\n runGuardedMutation?: GuardedMutationRunner\n useCanonicalInteractions?: boolean\n}\n\nexport function CompanyDashboardTab({ data, companyId, onTabChange, onActivityCreated, onScheduleRequested, runGuardedMutation, useCanonicalInteractions }: CompanyDashboardTabProps) {\n const t = useT()\n\n const activeDeals = React.useMemo(() => getActiveDeals(data.deals), [data.deals])\n const activeDealsValue = React.useMemo(() => sumActiveDeals(data.deals), [data.deals])\n const openTasks = React.useMemo(() => getOpenTasks(data.todos), [data.todos])\n const upcomingMeetings = React.useMemo(() => getUpcomingMeetings(data.interactions), [data.interactions])\n const recentActivity = React.useMemo(() => getRecentActivity(data.interactions), [data.interactions])\n\n const dealCurrency = activeDeals[0]?.valueCurrency ?? data.deals[0]?.valueCurrency ?? 'PLN'\n const activityTrend = React.useMemo(() => computeActivityTrend(data.interactions), [data.interactions])\n const dealTrend = React.useMemo(() => computeDealTrend(data.deals), [data.deals])\n const healthScore = React.useMemo(() => computeHealthScore(data.interactions), [data.interactions])\n\n const ltvValue = React.useMemo(() => {\n const wonDeals = data.deals.filter((d) => d.status === 'won')\n if (wonDeals.length === 0) return null\n return wonDeals.reduce((sum, d) => {\n const amt = typeof d.valueAmount === 'number' ? d.valueAmount : parseFloat(String(d.valueAmount ?? '0'))\n return sum + (Number.isFinite(amt) ? amt : 0)\n }, 0)\n }, [data.deals])\n\n const clientTenureYears = React.useMemo(() => {\n const allDates = data.interactions\n .map((i) => i.occurredAt ?? i.scheduledAt ?? i.createdAt)\n .filter(Boolean)\n .map((d) => new Date(d).getTime())\n if (allDates.length === 0) return null\n const earliest = Math.min(...allDates)\n return Math.floor((Date.now() - earliest) / (365.25 * 86_400_000))\n }, [data.interactions])\n\n const [hiddenTiles, setHiddenTiles] = React.useState<Set<string>>(\n () => new Set(readJsonFromLocalStorage<string[]>('om:dashboard-hidden-tiles', [])),\n )\n\n const toggleTile = React.useCallback((tileId: string) => {\n setHiddenTiles((prev) => {\n const next = new Set(prev)\n if (next.has(tileId)) next.delete(tileId)\n else next.add(tileId)\n writeJsonToLocalStorage('om:dashboard-hidden-tiles', [...next])\n return next\n })\n }, [])\n\n const kpiTiles: Array<{ id: string; title: string; value: number | null; trend?: KpiTrend; formatValue?: (v: number) => string; comparisonLabel: string }> = [\n { id: 'activeDeals', title: t('customers.companies.dashboard.kpi.activeDeals', 'ACTIVE DEALS'), value: activeDealsValue, trend: dealTrend, formatValue: (v: number) => formatCurrency(v, dealCurrency), comparisonLabel: `${activeDeals.length} ${activeDeals.length === 1 ? 'pipeline' : 'pipelines'}` },\n { id: 'activities', title: t('customers.companies.dashboard.kpi.activities', 'ACTIVITIES'), value: data.interactions.length, trend: activityTrend, comparisonLabel: t('customers.companies.dashboard.kpi.last12months', 'last 12 months') },\n { id: 'ltv', title: t('customers.companies.dashboard.kpi.ltv', 'CUSTOMER VALUE (LTV)'), value: ltvValue, formatValue: ltvValue !== null ? (v: number) => formatCurrency(v, dealCurrency) : undefined, comparisonLabel: ltvValue !== null ? t('customers.companies.dashboard.kpi.wonDeals', 'won deals total') : t('customers.companies.dashboard.kpi.noWonDeals', 'No won deals') },\n { id: 'clientSince', title: t('customers.companies.dashboard.kpi.clientSince', 'CLIENT SINCE'), value: clientTenureYears, formatValue: clientTenureYears !== null ? (v: number) => v < 1 ? `< 1 ${t('customers.companies.dashboard.kpi.year', 'year')}` : `${v} ${v === 1 ? t('customers.companies.dashboard.kpi.year', 'year') : t('customers.companies.dashboard.kpi.years', 'years')}` : undefined, comparisonLabel: clientTenureYears !== null ? `${data.deals.filter(d => d.status === 'won').length} ${t('customers.companies.dashboard.kpi.completedDeals', 'completed deals')}` : t('customers.companies.dashboard.kpi.noInteractions', 'No interactions yet') },\n ]\n\n return (\n <div className=\"space-y-6\">\n <div className=\"grid grid-cols-2 gap-4 lg:grid-cols-4\">\n {kpiTiles.filter((tile) => !hiddenTiles.has(tile.id)).map((tile) => (\n <div key={tile.id} className=\"relative group\">\n <KpiCard\n title={tile.title}\n value={tile.value}\n trend={tile.trend}\n formatValue={tile.formatValue}\n comparisonLabel={tile.comparisonLabel}\n />\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => toggleTile(tile.id)}\n className=\"h-auto absolute top-2 right-2 opacity-0 group-hover:opacity-60 transition-opacity text-muted-foreground hover:text-foreground p-0\"\n aria-label={t('customers.companies.dashboard.hideTile', 'Hide tile')}\n >\n <EyeOff className=\"size-3.5\" />\n </IconButton>\n </div>\n ))}\n </div>\n {hiddenTiles.size > 0 && (\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs text-muted-foreground\">\n {t('customers.companies.dashboard.hiddenTiles', '{{count}} tiles hidden', { count: hiddenTiles.size })}\n </span>\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" className=\"h-auto text-xs px-1.5 py-0.5\" onClick={() => { setHiddenTiles(new Set()); removeLocalStorageKey('om:dashboard-hidden-tiles') }}>\n {t('customers.companies.dashboard.showAll', 'Show all')}\n </Button>\n </div>\n )}\n\n <div className=\"grid grid-cols-1 gap-6 lg:grid-cols-[1fr_380px]\">\n <div className=\"space-y-6\">\n <UpcomingMeetingsWidget meetings={upcomingMeetings} t={t} />\n <OpenTasksWidget tasks={openTasks} t={t} onViewAll={() => onTabChange('activity-log')} currentUserId={null} />\n </div>\n <div className=\"space-y-6\">\n <RelationshipHealthWidget health={healthScore} t={t} />\n <ActiveDealWidget deals={activeDeals} t={t} />\n </div>\n </div>\n\n <InlineActivityComposer\n entityType=\"company\"\n entityId={companyId}\n onActivityCreated={onActivityCreated}\n runGuardedMutation={runGuardedMutation}\n onScheduleRequested={onScheduleRequested}\n useCanonicalInteractions={useCanonicalInteractions}\n />\n\n <RecentActivityWidget interactions={recentActivity} t={t} />\n </div>\n )\n}\n"],
5
- "mappings": "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",
6
- "names": []
7
- }
@@ -1,161 +0,0 @@
1
- "use client"
2
-
3
- import * as React from 'react'
4
- import { EyeOff } from 'lucide-react'
5
- import { useT } from '@open-mercato/shared/lib/i18n/context'
6
- import { KpiCard, type KpiTrend } from '@open-mercato/ui/backend/charts/KpiCard'
7
- import { Button } from '@open-mercato/ui/primitives/button'
8
- import { IconButton } from '@open-mercato/ui/primitives/icon-button'
9
- import { InlineActivityComposer } from './InlineActivityComposer'
10
- import type { CompanyOverview } from '../formConfig'
11
- import { formatCurrency } from './utils'
12
- import { computeHealthScore } from './healthScoreUtils'
13
- import {
14
- readJsonFromLocalStorage,
15
- writeJsonToLocalStorage,
16
- removeLocalStorageKey,
17
- } from '@open-mercato/shared/lib/browser/safeLocalStorage'
18
- import {
19
- sumActiveDeals,
20
- getActiveDeals,
21
- getOpenTasks,
22
- getUpcomingMeetings,
23
- getRecentActivity,
24
- computeActivityTrend,
25
- computeDealTrend,
26
- } from './dashboard/helpers'
27
- import { UpcomingMeetingsWidget } from './dashboard/UpcomingMeetingsWidget'
28
- import { RelationshipHealthWidget } from './dashboard/RelationshipHealthWidget'
29
- import { ActiveDealWidget } from './dashboard/ActiveDealWidget'
30
- import { OpenTasksWidget } from './dashboard/OpenTasksWidget'
31
- import { RecentActivityWidget } from './dashboard/RecentActivityWidget'
32
-
33
- type GuardedMutationRunner = <T>(
34
- operation: () => Promise<T>,
35
- mutationPayload?: Record<string, unknown>,
36
- ) => Promise<T>
37
-
38
- type CompanyDashboardTabProps = {
39
- data: CompanyOverview
40
- companyId: string
41
- onTabChange: (tab: string) => void
42
- onActivityCreated?: () => void
43
- onScheduleRequested?: () => void
44
- runGuardedMutation?: GuardedMutationRunner
45
- useCanonicalInteractions?: boolean
46
- }
47
-
48
- export function CompanyDashboardTab({ data, companyId, onTabChange, onActivityCreated, onScheduleRequested, runGuardedMutation, useCanonicalInteractions }: CompanyDashboardTabProps) {
49
- const t = useT()
50
-
51
- const activeDeals = React.useMemo(() => getActiveDeals(data.deals), [data.deals])
52
- const activeDealsValue = React.useMemo(() => sumActiveDeals(data.deals), [data.deals])
53
- const openTasks = React.useMemo(() => getOpenTasks(data.todos), [data.todos])
54
- const upcomingMeetings = React.useMemo(() => getUpcomingMeetings(data.interactions), [data.interactions])
55
- const recentActivity = React.useMemo(() => getRecentActivity(data.interactions), [data.interactions])
56
-
57
- const dealCurrency = activeDeals[0]?.valueCurrency ?? data.deals[0]?.valueCurrency ?? 'PLN'
58
- const activityTrend = React.useMemo(() => computeActivityTrend(data.interactions), [data.interactions])
59
- const dealTrend = React.useMemo(() => computeDealTrend(data.deals), [data.deals])
60
- const healthScore = React.useMemo(() => computeHealthScore(data.interactions), [data.interactions])
61
-
62
- const ltvValue = React.useMemo(() => {
63
- const wonDeals = data.deals.filter((d) => d.status === 'won')
64
- if (wonDeals.length === 0) return null
65
- return wonDeals.reduce((sum, d) => {
66
- const amt = typeof d.valueAmount === 'number' ? d.valueAmount : parseFloat(String(d.valueAmount ?? '0'))
67
- return sum + (Number.isFinite(amt) ? amt : 0)
68
- }, 0)
69
- }, [data.deals])
70
-
71
- const clientTenureYears = React.useMemo(() => {
72
- const allDates = data.interactions
73
- .map((i) => i.occurredAt ?? i.scheduledAt ?? i.createdAt)
74
- .filter(Boolean)
75
- .map((d) => new Date(d).getTime())
76
- if (allDates.length === 0) return null
77
- const earliest = Math.min(...allDates)
78
- return Math.floor((Date.now() - earliest) / (365.25 * 86_400_000))
79
- }, [data.interactions])
80
-
81
- const [hiddenTiles, setHiddenTiles] = React.useState<Set<string>>(
82
- () => new Set(readJsonFromLocalStorage<string[]>('om:dashboard-hidden-tiles', [])),
83
- )
84
-
85
- const toggleTile = React.useCallback((tileId: string) => {
86
- setHiddenTiles((prev) => {
87
- const next = new Set(prev)
88
- if (next.has(tileId)) next.delete(tileId)
89
- else next.add(tileId)
90
- writeJsonToLocalStorage('om:dashboard-hidden-tiles', [...next])
91
- return next
92
- })
93
- }, [])
94
-
95
- const kpiTiles: Array<{ id: string; title: string; value: number | null; trend?: KpiTrend; formatValue?: (v: number) => string; comparisonLabel: string }> = [
96
- { id: 'activeDeals', title: t('customers.companies.dashboard.kpi.activeDeals', 'ACTIVE DEALS'), value: activeDealsValue, trend: dealTrend, formatValue: (v: number) => formatCurrency(v, dealCurrency), comparisonLabel: `${activeDeals.length} ${activeDeals.length === 1 ? 'pipeline' : 'pipelines'}` },
97
- { id: 'activities', title: t('customers.companies.dashboard.kpi.activities', 'ACTIVITIES'), value: data.interactions.length, trend: activityTrend, comparisonLabel: t('customers.companies.dashboard.kpi.last12months', 'last 12 months') },
98
- { id: 'ltv', title: t('customers.companies.dashboard.kpi.ltv', 'CUSTOMER VALUE (LTV)'), value: ltvValue, formatValue: ltvValue !== null ? (v: number) => formatCurrency(v, dealCurrency) : undefined, comparisonLabel: ltvValue !== null ? t('customers.companies.dashboard.kpi.wonDeals', 'won deals total') : t('customers.companies.dashboard.kpi.noWonDeals', 'No won deals') },
99
- { id: 'clientSince', title: t('customers.companies.dashboard.kpi.clientSince', 'CLIENT SINCE'), value: clientTenureYears, formatValue: clientTenureYears !== null ? (v: number) => v < 1 ? `< 1 ${t('customers.companies.dashboard.kpi.year', 'year')}` : `${v} ${v === 1 ? t('customers.companies.dashboard.kpi.year', 'year') : t('customers.companies.dashboard.kpi.years', 'years')}` : undefined, comparisonLabel: clientTenureYears !== null ? `${data.deals.filter(d => d.status === 'won').length} ${t('customers.companies.dashboard.kpi.completedDeals', 'completed deals')}` : t('customers.companies.dashboard.kpi.noInteractions', 'No interactions yet') },
100
- ]
101
-
102
- return (
103
- <div className="space-y-6">
104
- <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
105
- {kpiTiles.filter((tile) => !hiddenTiles.has(tile.id)).map((tile) => (
106
- <div key={tile.id} className="relative group">
107
- <KpiCard
108
- title={tile.title}
109
- value={tile.value}
110
- trend={tile.trend}
111
- formatValue={tile.formatValue}
112
- comparisonLabel={tile.comparisonLabel}
113
- />
114
- <IconButton
115
- type="button"
116
- variant="ghost"
117
- size="sm"
118
- onClick={() => toggleTile(tile.id)}
119
- className="h-auto absolute top-2 right-2 opacity-0 group-hover:opacity-60 transition-opacity text-muted-foreground hover:text-foreground p-0"
120
- aria-label={t('customers.companies.dashboard.hideTile', 'Hide tile')}
121
- >
122
- <EyeOff className="size-3.5" />
123
- </IconButton>
124
- </div>
125
- ))}
126
- </div>
127
- {hiddenTiles.size > 0 && (
128
- <div className="flex items-center gap-2">
129
- <span className="text-xs text-muted-foreground">
130
- {t('customers.companies.dashboard.hiddenTiles', '{{count}} tiles hidden', { count: hiddenTiles.size })}
131
- </span>
132
- <Button type="button" variant="ghost" size="sm" className="h-auto text-xs px-1.5 py-0.5" onClick={() => { setHiddenTiles(new Set()); removeLocalStorageKey('om:dashboard-hidden-tiles') }}>
133
- {t('customers.companies.dashboard.showAll', 'Show all')}
134
- </Button>
135
- </div>
136
- )}
137
-
138
- <div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_380px]">
139
- <div className="space-y-6">
140
- <UpcomingMeetingsWidget meetings={upcomingMeetings} t={t} />
141
- <OpenTasksWidget tasks={openTasks} t={t} onViewAll={() => onTabChange('activity-log')} currentUserId={null} />
142
- </div>
143
- <div className="space-y-6">
144
- <RelationshipHealthWidget health={healthScore} t={t} />
145
- <ActiveDealWidget deals={activeDeals} t={t} />
146
- </div>
147
- </div>
148
-
149
- <InlineActivityComposer
150
- entityType="company"
151
- entityId={companyId}
152
- onActivityCreated={onActivityCreated}
153
- runGuardedMutation={runGuardedMutation}
154
- onScheduleRequested={onScheduleRequested}
155
- useCanonicalInteractions={useCanonicalInteractions}
156
- />
157
-
158
- <RecentActivityWidget interactions={recentActivity} t={t} />
159
- </div>
160
- )
161
- }