@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +26 -22
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyKpiBar.js +8 -7
- package/dist/modules/customers/components/detail/CompanyKpiBar.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyPeopleSection.js +6 -5
- package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
- package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +32 -10
- package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
- package/dist/modules/data_sync/components/IntegrationScheduleTab.js +77 -32
- package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.js +6 -0
- package/dist/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.js.map +2 -2
- package/dist/modules/dictionaries/api/[dictionaryId]/route.js +6 -0
- package/dist/modules/dictionaries/api/[dictionaryId]/route.js.map +2 -2
- package/dist/modules/messages/api/route.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/customers/api/people/[id]/route.ts +7 -0
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +29 -22
- package/src/modules/customers/components/detail/CompanyKpiBar.tsx +8 -7
- package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +6 -5
- package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +32 -10
- package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +77 -33
- package/src/modules/dictionaries/api/[dictionaryId]/entries/[entryId]/route.ts +7 -0
- package/src/modules/dictionaries/api/[dictionaryId]/route.ts +7 -0
- package/src/modules/messages/api/route.ts +8 -0
- package/dist/modules/customers/components/detail/CompanyDashboardTab.js +0 -133
- package/dist/modules/customers/components/detail/CompanyDashboardTab.js.map +0 -7
- 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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
}
|