@open-mercato/core 0.7.1-develop.7122.1.421cefe668 → 0.7.1-develop.7130.1.fef2396fd8
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/deals/aggregate/route.js +85 -0
- package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js +5 -117
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +1 -3
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/commands/deals.js +33 -9
- package/dist/modules/customers/commands/deals.js.map +3 -3
- package/dist/modules/customers/components/AddressEditor.js +71 -1
- package/dist/modules/customers/components/AddressEditor.js.map +2 -2
- package/dist/modules/customers/utils/addressFormat.js +18 -2
- package/dist/modules/customers/utils/addressFormat.js.map +2 -2
- package/dist/modules/sales/components/documents/AddressesSection.js +17 -2
- package/dist/modules/sales/components/documents/AddressesSection.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesDocumentForm.js +7 -24
- package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
- package/dist/modules/sales/components/documents/normalizeAddressDraft.js +29 -0
- package/dist/modules/sales/components/documents/normalizeAddressDraft.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/customers/api/deals/aggregate/route.ts +102 -0
- package/src/modules/customers/backend/customers/deals/[id]/hooks/types.ts +0 -23
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.ts +11 -172
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +1 -1
- package/src/modules/customers/commands/deals.ts +84 -9
- package/src/modules/customers/components/AddressEditor.tsx +116 -1
- package/src/modules/customers/i18n/de.json +6 -0
- package/src/modules/customers/i18n/en.json +6 -0
- package/src/modules/customers/i18n/es.json +6 -0
- package/src/modules/customers/i18n/ko.json +6 -0
- package/src/modules/customers/i18n/pl.json +6 -0
- package/src/modules/customers/utils/addressFormat.tsx +59 -1
- package/src/modules/sales/components/documents/AddressesSection.tsx +48 -5
- package/src/modules/sales/components/documents/SalesDocumentForm.tsx +5 -23
- package/src/modules/sales/components/documents/normalizeAddressDraft.ts +29 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { NextResponse } from "next/server";
|
|
2
3
|
import { z } from "zod";
|
|
4
|
+
import { runWithCacheTenant } from "@open-mercato/cache";
|
|
3
5
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
6
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
7
|
import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
|
|
@@ -7,6 +9,12 @@ import { resolveDealsOrganizationIds } from "../../../lib/dealsOrganizationScope
|
|
|
7
9
|
import { resolveOptionalBaseCurrencyCode } from "../../../lib/optionalBaseCurrency.js";
|
|
8
10
|
import { parseBooleanFromUnknown } from "@open-mercato/shared/lib/boolean";
|
|
9
11
|
import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern";
|
|
12
|
+
import {
|
|
13
|
+
buildCollectionTags,
|
|
14
|
+
debugCrudCache,
|
|
15
|
+
isCrudCacheEnabled,
|
|
16
|
+
resolveCrudCache
|
|
17
|
+
} from "@open-mercato/shared/lib/crud/cache";
|
|
10
18
|
import { readQueryParamList } from "@open-mercato/shared/lib/crud/query-params";
|
|
11
19
|
import { isTenantDataEncryptionEnabled } from "@open-mercato/shared/lib/encryption/toggles";
|
|
12
20
|
import { fetchStuckDealIds } from "../../../lib/stuckDeals.js";
|
|
@@ -15,6 +23,8 @@ import { findMatchingEntityIdsBySearchTokensAcrossSources } from "../../utils.js
|
|
|
15
23
|
import { E } from "../../../../../generated/entities.ids.generated.js";
|
|
16
24
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
17
25
|
const logger = createLogger("customers");
|
|
26
|
+
const DEALS_AGGREGATE_CACHE_RESOURCE = "customers.deal";
|
|
27
|
+
const DEALS_AGGREGATE_CACHE_TTL_MS = 3e4;
|
|
18
28
|
const metadata = {
|
|
19
29
|
GET: { requireAuth: true, requireFeatures: ["customers.deals.view"] }
|
|
20
30
|
};
|
|
@@ -36,6 +46,29 @@ const querySchema = z.object({
|
|
|
36
46
|
expectedCloseAtFrom: z.string().optional(),
|
|
37
47
|
expectedCloseAtTo: z.string().optional()
|
|
38
48
|
});
|
|
49
|
+
function normalizeStringSet(values) {
|
|
50
|
+
return Array.from(new Set((values ?? []).map((value) => value.trim()))).sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
51
|
+
}
|
|
52
|
+
function buildDealsAggregateCacheKey(params) {
|
|
53
|
+
const signature = {
|
|
54
|
+
tenantId: params.tenantId,
|
|
55
|
+
currencyScopeOrganizationId: params.currencyScopeOrganizationId,
|
|
56
|
+
organizationIds: normalizeStringSet(params.organizationIds),
|
|
57
|
+
pipelineId: params.query.pipelineId ?? null,
|
|
58
|
+
status: normalizeStringSet(params.query.status),
|
|
59
|
+
ownerUserId: normalizeStringSet(params.query.ownerUserId),
|
|
60
|
+
personId: normalizeStringSet(params.query.personId),
|
|
61
|
+
companyId: normalizeStringSet(params.query.companyId),
|
|
62
|
+
expectedCloseAtFrom: params.query.expectedCloseAtFrom ?? null,
|
|
63
|
+
expectedCloseAtTo: params.query.expectedCloseAtTo ?? null,
|
|
64
|
+
isOverdue: params.query.isOverdue === true
|
|
65
|
+
};
|
|
66
|
+
const digest = createHash("sha256").update(JSON.stringify(signature)).digest("hex");
|
|
67
|
+
return `customers:deal:aggregate:v1:${digest}`;
|
|
68
|
+
}
|
|
69
|
+
function isDealsAggregateCacheEligible(searchParams) {
|
|
70
|
+
return !searchParams.has("search") && !searchParams.has("isStuck");
|
|
71
|
+
}
|
|
39
72
|
const stageBreakdownByCurrencySchema = z.object({
|
|
40
73
|
currency: z.string(),
|
|
41
74
|
total: z.number(),
|
|
@@ -119,6 +152,37 @@ async function GET(req) {
|
|
|
119
152
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
120
153
|
}
|
|
121
154
|
const orgFilterIds = await resolveDealsOrganizationIds({ em, scope, auth, tenantId: effectiveTenantId });
|
|
155
|
+
const aggregateCache = isDealsAggregateCacheEligible(params) && isCrudCacheEnabled() ? resolveCrudCache(container) : null;
|
|
156
|
+
const aggregateCacheKey = aggregateCache ? buildDealsAggregateCacheKey({
|
|
157
|
+
tenantId: effectiveTenantId,
|
|
158
|
+
currencyScopeOrganizationId: orgFilterIds[0] ?? null,
|
|
159
|
+
organizationIds: orgFilterIds,
|
|
160
|
+
query: parsed.data
|
|
161
|
+
}) : null;
|
|
162
|
+
if (aggregateCache && aggregateCacheKey) {
|
|
163
|
+
try {
|
|
164
|
+
const cached = await runWithCacheTenant(
|
|
165
|
+
effectiveTenantId,
|
|
166
|
+
() => aggregateCache.get(aggregateCacheKey)
|
|
167
|
+
);
|
|
168
|
+
const cachedResponse = aggregateResponseSchema.safeParse(cached);
|
|
169
|
+
if (cachedResponse.success) {
|
|
170
|
+
return NextResponse.json(cachedResponse.data);
|
|
171
|
+
}
|
|
172
|
+
if (cached !== null && cached !== void 0) {
|
|
173
|
+
debugCrudCache("get-invalid", {
|
|
174
|
+
resource: DEALS_AGGREGATE_CACHE_RESOURCE,
|
|
175
|
+
key: aggregateCacheKey
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
debugCrudCache("get", {
|
|
180
|
+
resource: DEALS_AGGREGATE_CACHE_RESOURCE,
|
|
181
|
+
key: aggregateCacheKey,
|
|
182
|
+
error: err instanceof Error ? err.message : String(err)
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
122
186
|
const baseCurrencyCode = await resolveOptionalBaseCurrencyCode(
|
|
123
187
|
container,
|
|
124
188
|
effectiveTenantId,
|
|
@@ -331,6 +395,27 @@ async function GET(req) {
|
|
|
331
395
|
baseCurrencyCode,
|
|
332
396
|
perStage: Array.from(stageMap.values())
|
|
333
397
|
};
|
|
398
|
+
if (aggregateCache && aggregateCacheKey) {
|
|
399
|
+
try {
|
|
400
|
+
await runWithCacheTenant(
|
|
401
|
+
effectiveTenantId,
|
|
402
|
+
() => aggregateCache.set(aggregateCacheKey, response, {
|
|
403
|
+
ttl: DEALS_AGGREGATE_CACHE_TTL_MS,
|
|
404
|
+
tags: buildCollectionTags(
|
|
405
|
+
DEALS_AGGREGATE_CACHE_RESOURCE,
|
|
406
|
+
effectiveTenantId,
|
|
407
|
+
normalizeStringSet(orgFilterIds)
|
|
408
|
+
)
|
|
409
|
+
})
|
|
410
|
+
);
|
|
411
|
+
} catch (err) {
|
|
412
|
+
debugCrudCache("store", {
|
|
413
|
+
resource: DEALS_AGGREGATE_CACHE_RESOURCE,
|
|
414
|
+
key: aggregateCacheKey,
|
|
415
|
+
error: err instanceof Error ? err.message : String(err)
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
334
419
|
return NextResponse.json(response);
|
|
335
420
|
}
|
|
336
421
|
export {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/customers/api/deals/aggregate/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager as CoreEntityManager } from '@mikro-orm/core'\nimport type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveDealsOrganizationIds } from '../../../lib/dealsOrganizationScope'\nimport { resolveOptionalBaseCurrencyCode } from '../../../lib/optionalBaseCurrency'\nimport type { ExchangeRateService } from '@open-mercato/core/modules/currencies/services/exchangeRateService'\nimport { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport { readQueryParamList } from '@open-mercato/shared/lib/crud/query-params'\nimport { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { fetchStuckDealIds } from '../../../lib/stuckDeals'\nimport { expandDealStatusAliases } from '../../../lib/dealStatus'\nimport { findMatchingEntityIdsBySearchTokensAcrossSources } from '../../utils'\nimport { E } from '#generated/entities.ids.generated'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.deals.view'] },\n}\n\nconst querySchema = z.object({\n pipelineId: z.string().uuid().optional(),\n search: z.string().optional(),\n status: z.array(z.string().max(50)).max(20).optional(),\n ownerUserId: z.array(z.string().uuid()).optional(),\n personId: z.array(z.string().uuid()).optional(),\n companyId: z.array(z.string().uuid()).optional(),\n isStuck: z.preprocess((value) => {\n const parsed = parseBooleanFromUnknown(value)\n return parsed === null ? value : parsed\n }, z.boolean()).optional(),\n isOverdue: z.preprocess((value) => {\n const parsed = parseBooleanFromUnknown(value)\n return parsed === null ? value : parsed\n }, z.boolean()).optional(),\n expectedCloseAtFrom: z.string().optional(),\n expectedCloseAtTo: z.string().optional(),\n})\n\ntype StageBreakdownByCurrency = {\n currency: string\n total: number\n count: number\n}\n\ntype StageAggregate = {\n stageId: string\n count: number\n openCount: number\n totalInBaseCurrency: number\n byCurrency: StageBreakdownByCurrency[]\n /**\n * `true` when every currency present in `byCurrency` was either the base currency or\n * had a usable exchange rate at request time. `false` when at least one currency in\n * `byCurrency` could not be resolved to base \u2014 the lane header should surface a\n * \"partial\" indicator in that case so the converted total isn't read as authoritative.\n */\n convertedAll: boolean\n /**\n * Currencies present in `byCurrency` that had NO exchange rate to the base currency.\n * Empty when conversion was complete or there is no base currency configured. The\n * client uses this to disclose which slice of the value is excluded from `totalInBaseCurrency`.\n */\n missingRateCurrencies: string[]\n}\n\ntype AggregateResponse = {\n baseCurrencyCode: string | null\n perStage: StageAggregate[]\n}\n\nconst stageBreakdownByCurrencySchema = z.object({\n currency: z.string(),\n total: z.number(),\n count: z.number(),\n})\n\nconst stageAggregateSchema = z.object({\n stageId: z.string(),\n count: z.number(),\n openCount: z.number(),\n totalInBaseCurrency: z.number(),\n byCurrency: z.array(stageBreakdownByCurrencySchema),\n convertedAll: z.boolean(),\n missingRateCurrencies: z.array(z.string()),\n})\n\nconst aggregateResponseSchema = z.object({\n baseCurrencyCode: z.string().nullable(),\n perStage: z.array(stageAggregateSchema),\n})\n\nconst aggregateErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'Deals aggregate per pipeline stage',\n methods: {\n GET: {\n summary: 'Per-stage counts and currency totals for kanban lane headers',\n description:\n 'Returns per-stage counts and totals for deals, with values converted to the tenant base currency where rates are available. Used to power kanban lane headers without loading every deal.',\n query: querySchema,\n responses: [\n { status: 200, description: 'Per-stage aggregate payload', schema: aggregateResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid query parameters', schema: aggregateErrorSchema },\n { status: 401, description: 'Unauthorized', schema: aggregateErrorSchema },\n ],\n },\n },\n}\n\nfunction readArrayParam(searchParams: URLSearchParams, key: string): string[] | null {\n const values = readQueryParamList(searchParams, key)\n return values.length ? values : null\n}\n\nfunction restrictToIds(where: string[], values: Array<string | number | null>, ids: string[]) {\n if (ids.length === 0) {\n where.push('id = ?')\n values.push('00000000-0000-0000-0000-000000000000')\n return\n }\n const placeholders = ids.map(() => '?').join(',')\n where.push(`id IN (${placeholders})`)\n values.push(...ids)\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const url = new URL(req.url)\n const params = url.searchParams\n const parsed = querySchema.safeParse({\n pipelineId: params.get('pipelineId') ?? undefined,\n search: params.get('search') ?? undefined,\n status: readArrayParam(params, 'status') ?? undefined,\n ownerUserId: readArrayParam(params, 'ownerUserId') ?? undefined,\n personId: readArrayParam(params, 'personId') ?? undefined,\n companyId: readArrayParam(params, 'companyId') ?? undefined,\n isStuck: params.get('isStuck') ?? undefined,\n isOverdue: params.get('isOverdue') ?? undefined,\n expectedCloseAtFrom: params.get('expectedCloseAtFrom') ?? undefined,\n expectedCloseAtTo: params.get('expectedCloseAtTo') ?? undefined,\n })\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })\n }\n\n const container = await createRequestContainer()\n const em = container.resolve<CoreEntityManager>('em')\n\n // Resolve organization scope from the request so multi-org operators can aggregate\n // deals for any org their RBAC scope permits (matching the deal detail route at\n // [id]/route.ts:360). Falls back to `auth.orgId` when no scope cookie is set or\n // when rbacService cannot be resolved (e.g. in unit tests with a minimal container).\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const effectiveTenantId = scope.tenantId ?? auth.tenantId\n if (!effectiveTenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const orgFilterIds = await resolveDealsOrganizationIds({ em, scope, auth, tenantId: effectiveTenantId })\n\n // Raw SQL is used here intentionally \u2014 the route only projects non-encrypted columns\n // (`pipeline_stage_id`, `value_amount`, `value_currency`, `status`, plus filters). It\n // avoids the per-row decryption cost that would be paid by `findWithDecryption` for an\n // aggregate that never reads `title`/`description`. The search path still relies on\n // the token index above to find matching deals when encrypted columns are involved.\n const baseCurrencyCode = await resolveOptionalBaseCurrencyCode(\n container,\n effectiveTenantId,\n orgFilterIds[0],\n )\n\n // Build WHERE clause shared between count + sum queries\n const orgPlaceholders = orgFilterIds.map(() => '?').join(',')\n const where: string[] = [\n 'tenant_id = ?',\n `organization_id IN (${orgPlaceholders})`,\n 'deleted_at IS NULL',\n ]\n const values: Array<string | number | null> = [effectiveTenantId, ...orgFilterIds]\n\n if (parsed.data.pipelineId) {\n where.push('pipeline_id = ?')\n values.push(parsed.data.pipelineId)\n }\n const search = parsed.data.search?.trim()\n if (search) {\n const searchCtx: CrudCtx = {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: orgFilterIds[0],\n organizationIds: orgFilterIds,\n request: req,\n }\n const matchingIds = await findMatchingEntityIdsBySearchTokensAcrossSources({\n ctx: searchCtx,\n query: search,\n sources: [\n {\n entityType: E.customers.customer_deal,\n fields: [\n 'title',\n 'description',\n 'status',\n 'pipeline_stage',\n 'source',\n 'value_amount',\n 'value_currency',\n 'cf:competitive_risk',\n 'cf:implementation_complexity',\n ],\n },\n ],\n })\n if (matchingIds !== null && matchingIds.length > 0) {\n restrictToIds(where, values, matchingIds)\n } else if (isTenantDataEncryptionEnabled()) {\n // `customers:customer_deal.title` and `.description` are declared in\n // `encryption.ts` as encrypted columns. A raw `ILIKE` over the ciphertext would\n // silently match nothing on encrypted tenants and produce misleading lane totals,\n // so we collapse the result to zero rows instead \u2014 consistent with the list\n // endpoint's behavior when the token index has nothing to offer.\n restrictToIds(where, values, [])\n } else {\n const searchPattern = `%${escapeLikePattern(search)}%`\n where.push(\"(title ILIKE ? ESCAPE '\\\\' OR description ILIKE ? ESCAPE '\\\\')\")\n values.push(searchPattern, searchPattern)\n }\n }\n if (parsed.data.status && parsed.data.status.length) {\n // Non-empty input always expands to a non-empty set, so this always narrows.\n const expandedStatuses = expandDealStatusAliases(parsed.data.status)\n const placeholders = expandedStatuses.map(() => '?').join(',')\n where.push(`status IN (${placeholders})`)\n values.push(...expandedStatuses)\n }\n if (parsed.data.ownerUserId && parsed.data.ownerUserId.length) {\n const placeholders = parsed.data.ownerUserId.map(() => '?').join(',')\n where.push(`owner_user_id IN (${placeholders})`)\n values.push(...parsed.data.ownerUserId)\n }\n if (parsed.data.expectedCloseAtFrom) {\n where.push('expected_close_at >= ?')\n values.push(parsed.data.expectedCloseAtFrom)\n }\n if (parsed.data.expectedCloseAtTo) {\n where.push('expected_close_at <= ?')\n values.push(parsed.data.expectedCloseAtTo)\n }\n if (parsed.data.isOverdue) {\n // Mirror the list route's precedence: the caller-supplied status filter wins, and\n // status='open' is injected only when none was provided.\n const hasCallerStatus = !!parsed.data.status?.length\n if (hasCallerStatus) {\n where.push('expected_close_at < CURRENT_DATE')\n } else {\n where.push(\"expected_close_at < CURRENT_DATE AND status = 'open'\")\n }\n }\n if (parsed.data.isStuck) {\n // Reuse the list endpoint's stuck-deal lookup so kanban headers, lane counts, and the\n // cards rendered inside each lane agree on the same definition of \"stuck\". Empty result\n // \u2192 narrow to a sentinel UUID so the WHERE clause collapses to zero rows.\n const stuckIds = await fetchStuckDealIds(em as unknown as PgEntityManager, orgFilterIds[0], effectiveTenantId)\n restrictToIds(where, values, stuckIds)\n }\n if (parsed.data.personId && parsed.data.personId.length) {\n const placeholders = parsed.data.personId.map(() => '?').join(',')\n where.push(`EXISTS (SELECT 1 FROM customer_deal_people dp WHERE dp.deal_id = customer_deals.id AND dp.person_entity_id IN (${placeholders}))`)\n values.push(...parsed.data.personId)\n }\n if (parsed.data.companyId && parsed.data.companyId.length) {\n const placeholders = parsed.data.companyId.map(() => '?').join(',')\n where.push(`EXISTS (SELECT 1 FROM customer_deal_companies dc WHERE dc.deal_id = customer_deals.id AND dc.company_entity_id IN (${placeholders}))`)\n values.push(...parsed.data.companyId)\n }\n\n const whereSql = where.join(' AND ')\n\n // Aggregate by (stage, currency). Treat null stage as the literal `__unassigned`.\n const rows = await em.getConnection().execute<\n Array<{\n stage_id: string | null\n currency: string | null\n total: string | number | null\n count: string | number\n open_count: string | number\n }>\n >(\n `SELECT\n pipeline_stage_id AS stage_id,\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount), 0) AS total,\n COUNT(*) AS count,\n COUNT(*) FILTER (WHERE status = 'open') AS open_count\n FROM customer_deals\n WHERE ${whereSql}\n GROUP BY pipeline_stage_id, UPPER(COALESCE(value_currency, ''))`,\n values,\n )\n\n // Reduce to per-stage aggregates\n const stageMap = new Map<string, StageAggregate>()\n for (const row of rows) {\n const stageId = row.stage_id ?? '__unassigned'\n const total = Number(row.total ?? 0)\n const count = Number(row.count ?? 0)\n const openCount = Number(row.open_count ?? 0)\n const currency = (row.currency ?? '').toString().trim()\n\n if (!stageMap.has(stageId)) {\n stageMap.set(stageId, {\n stageId,\n count: 0,\n openCount: 0,\n totalInBaseCurrency: 0,\n byCurrency: [],\n convertedAll: true,\n missingRateCurrencies: [],\n })\n }\n const agg = stageMap.get(stageId)!\n agg.count += count\n agg.openCount += openCount\n // Include any row that has a real currency code, even when its summed amount is\n // zero \u2014 `byCurrency` should agree with `count` so the per-currency breakdown\n // doesn't silently drop deals whose amount happens to be zero. Rows with no\n // currency code at all still get folded into the stage totals via `count`.\n if (currency.length > 0) {\n agg.byCurrency.push({ currency, total, count })\n }\n }\n\n // Convert to base currency where possible\n if (baseCurrencyCode) {\n const exchange = container.resolve('exchangeRateService') as ExchangeRateService | undefined\n const today = new Date()\n const distinctCurrencies = new Set<string>()\n for (const agg of stageMap.values()) {\n for (const row of agg.byCurrency) {\n if (row.currency && row.currency !== baseCurrencyCode) {\n distinctCurrencies.add(row.currency)\n }\n }\n }\n\n const rateCache = new Map<string, number>()\n if (exchange && distinctCurrencies.size > 0) {\n const pairs = Array.from(distinctCurrencies).map((c) => ({\n fromCurrencyCode: c,\n toCurrencyCode: baseCurrencyCode,\n }))\n try {\n const results = await exchange.getRates({\n pairs,\n date: today,\n scope: { tenantId: effectiveTenantId, organizationId: orgFilterIds[0] },\n options: { maxDaysBack: 60, autoFetch: false },\n })\n for (const [key, rateResult] of results) {\n if (rateResult.rates.length > 0) {\n // Pick the first matching rate (sources are equivalent for display purposes)\n const rate = Number(rateResult.rates[0].rate)\n if (Number.isFinite(rate) && rate > 0) {\n rateCache.set(key, rate)\n }\n }\n }\n } catch (err) {\n // Swallow \u2014 partial totals are still useful and we'll fall back to currency-native\n // sums. Logging at warn level so operators can correlate missing-rate disclosures in\n // the UI (`convertedAll: false` / `missingRateCurrencies`) with the underlying error.\n logger.warn('exchange-rate lookup failed; falling back to per-currency totals', { component: 'deals.aggregate', err })\n }\n }\n\n for (const agg of stageMap.values()) {\n let totalBase = 0\n let convertedAll = true\n const missingRateCurrencies: string[] = []\n for (const row of agg.byCurrency) {\n if (!row.currency) continue\n if (row.currency === baseCurrencyCode) {\n totalBase += row.total\n continue\n }\n const key = `${row.currency}/${baseCurrencyCode}`\n const rate = rateCache.get(key)\n if (rate !== undefined) {\n totalBase += row.total * rate\n } else {\n convertedAll = false\n if (!missingRateCurrencies.includes(row.currency)) {\n missingRateCurrencies.push(row.currency)\n }\n }\n }\n // Even when some rates are missing, totalInBaseCurrency reflects the converted slice.\n // `convertedAll`/`missingRateCurrencies` let the client distinguish a complete\n // conversion from a partial one and disclose which currencies were excluded.\n agg.totalInBaseCurrency = Math.round(totalBase)\n agg.convertedAll = convertedAll\n agg.missingRateCurrencies = missingRateCurrencies\n }\n } else {\n // No base currency configured for the tenant \u2014 surface this by marking every stage as\n // \"not converted\" with all currencies flagged as missing rates. This way the client UI\n // can fall back to a per-currency breakdown without trying to render a board total.\n for (const agg of stageMap.values()) {\n const present = agg.byCurrency.map((row) => row.currency).filter((c): c is string => !!c)\n if (present.length > 0) {\n agg.convertedAll = false\n agg.missingRateCurrencies = Array.from(new Set(present))\n }\n }\n }\n\n // Sort byCurrency rows by total descending for stable display\n for (const agg of stageMap.values()) {\n agg.byCurrency.sort((a, b) => b.total - a.total)\n }\n\n const response: AggregateResponse = {\n baseCurrencyCode,\n perStage: Array.from(stageMap.values()),\n }\n\n return NextResponse.json(response)\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAGlB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,0CAA0C;AACnD,SAAS,mCAAmC;AAC5C,SAAS,uCAAuC;AAEhD,SAAS,+BAA+B;AACxC,SAAS,yBAAyB;AAElC,SAAS,0BAA0B;AACnC,SAAS,qCAAqC;AAE9C,SAAS,yBAAyB;AAClC,SAAS,+BAA+B;AACxC,SAAS,wDAAwD;AACjE,SAAS,SAAS;AAClB,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,WAAW;AAEhC,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,sBAAsB,EAAE;AACtE;AAEA,MAAM,cAAc,EAAE,OAAO;AAAA,EAC3B,YAAY,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EACvC,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE,SAAS;AAAA,EACrD,aAAa,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,SAAS;AAAA,EACjD,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,SAAS;AAAA,EAC9C,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,SAAS;AAAA,EAC/C,SAAS,EAAE,WAAW,CAAC,UAAU;AAC/B,UAAM,SAAS,wBAAwB,KAAK;AAC5C,WAAO,WAAW,OAAO,QAAQ;AAAA,EACnC,GAAG,EAAE,QAAQ,CAAC,EAAE,SAAS;AAAA,EACzB,WAAW,EAAE,WAAW,CAAC,UAAU;AACjC,UAAM,SAAS,wBAAwB,KAAK;AAC5C,WAAO,WAAW,OAAO,QAAQ;AAAA,EACnC,GAAG,EAAE,QAAQ,CAAC,EAAE,SAAS;AAAA,EACzB,qBAAqB,EAAE,OAAO,EAAE,SAAS;AAAA,EACzC,mBAAmB,EAAE,OAAO,EAAE,SAAS;AACzC,CAAC;AAkCD,MAAM,iCAAiC,EAAE,OAAO;AAAA,EAC9C,UAAU,EAAE,OAAO;AAAA,EACnB,OAAO,EAAE,OAAO;AAAA,EAChB,OAAO,EAAE,OAAO;AAClB,CAAC;AAED,MAAM,uBAAuB,EAAE,OAAO;AAAA,EACpC,SAAS,EAAE,OAAO;AAAA,EAClB,OAAO,EAAE,OAAO;AAAA,EAChB,WAAW,EAAE,OAAO;AAAA,EACpB,qBAAqB,EAAE,OAAO;AAAA,EAC9B,YAAY,EAAE,MAAM,8BAA8B;AAAA,EAClD,cAAc,EAAE,QAAQ;AAAA,EACxB,uBAAuB,EAAE,MAAM,EAAE,OAAO,CAAC;AAC3C,CAAC;AAED,MAAM,0BAA0B,EAAE,OAAO;AAAA,EACvC,kBAAkB,EAAE,OAAO,EAAE,SAAS;AAAA,EACtC,UAAU,EAAE,MAAM,oBAAoB;AACxC,CAAC;AAED,MAAM,uBAAuB,EAAE,OAAO;AAAA,EACpC,OAAO,EAAE,OAAO;AAClB,CAAC;AAEM,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,aACE;AAAA,MACF,OAAO;AAAA,MACP,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,+BAA+B,QAAQ,wBAAwB;AAAA,MAC7F;AAAA,MACA,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,4BAA4B,QAAQ,qBAAqB;AAAA,QACrF,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,qBAAqB;AAAA,MAC3E;AAAA,IACF;AAAA,EACF;AACF;AAEA,SAAS,eAAe,cAA+B,KAA8B;AACnF,QAAM,SAAS,mBAAmB,cAAc,GAAG;AACnD,SAAO,OAAO,SAAS,SAAS;AAClC;AAEA,SAAS,cAAc,OAAiB,QAAuC,KAAe;AAC5F,MAAI,IAAI,WAAW,GAAG;AACpB,UAAM,KAAK,QAAQ;AACnB,WAAO,KAAK,sCAAsC;AAClD;AAAA,EACF;AACA,QAAM,eAAe,IAAI,IAAI,MAAM,GAAG,EAAE,KAAK,GAAG;AAChD,QAAM,KAAK,UAAU,YAAY,GAAG;AACpC,SAAO,KAAK,GAAG,GAAG;AACpB;AAEA,eAAsB,IAAI,KAAc;AACtC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,UAAU;AACnB,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,QAAM,MAAM,IAAI,IAAI,IAAI,GAAG;AAC3B,QAAM,SAAS,IAAI;AACnB,QAAM,SAAS,YAAY,UAAU;AAAA,IACnC,YAAY,OAAO,IAAI,YAAY,KAAK;AAAA,IACxC,QAAQ,OAAO,IAAI,QAAQ,KAAK;AAAA,IAChC,QAAQ,eAAe,QAAQ,QAAQ,KAAK;AAAA,IAC5C,aAAa,eAAe,QAAQ,aAAa,KAAK;AAAA,IACtD,UAAU,eAAe,QAAQ,UAAU,KAAK;AAAA,IAChD,WAAW,eAAe,QAAQ,WAAW,KAAK;AAAA,IAClD,SAAS,OAAO,IAAI,SAAS,KAAK;AAAA,IAClC,WAAW,OAAO,IAAI,WAAW,KAAK;AAAA,IACtC,qBAAqB,OAAO,IAAI,qBAAqB,KAAK;AAAA,IAC1D,mBAAmB,OAAO,IAAI,mBAAmB,KAAK;AAAA,EACxD,CAAC;AACD,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,aAAa,KAAK,EAAE,OAAO,2BAA2B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACjF;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,KAAK,UAAU,QAA2B,IAAI;AAMpD,QAAM,QAAQ,MAAM,mCAAmC,EAAE,WAAW,MAAM,SAAS,IAAI,CAAC;AACxF,QAAM,oBAAoB,MAAM,YAAY,KAAK;AACjD,MAAI,CAAC,mBAAmB;AACtB,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AACA,QAAM,eAAe,MAAM,4BAA4B,EAAE,IAAI,OAAO,MAAM,UAAU,kBAAkB,CAAC;AAOvG,QAAM,mBAAmB,MAAM;AAAA,IAC7B;AAAA,IACA;AAAA,IACA,aAAa,CAAC;AAAA,EAChB;AAGA,QAAM,kBAAkB,aAAa,IAAI,MAAM,GAAG,EAAE,KAAK,GAAG;AAC5D,QAAM,QAAkB;AAAA,IACtB;AAAA,IACA,uBAAuB,eAAe;AAAA,IACtC;AAAA,EACF;AACA,QAAM,SAAwC,CAAC,mBAAmB,GAAG,YAAY;AAEjF,MAAI,OAAO,KAAK,YAAY;AAC1B,UAAM,KAAK,iBAAiB;AAC5B,WAAO,KAAK,OAAO,KAAK,UAAU;AAAA,EACpC;AACA,QAAM,SAAS,OAAO,KAAK,QAAQ,KAAK;AACxC,MAAI,QAAQ;AACV,UAAM,YAAqB;AAAA,MACzB;AAAA,MACA;AAAA,MACA,mBAAmB;AAAA,MACnB,wBAAwB,aAAa,CAAC;AAAA,MACtC,iBAAiB;AAAA,MACjB,SAAS;AAAA,IACX;AACA,UAAM,cAAc,MAAM,iDAAiD;AAAA,MACzE,KAAK;AAAA,MACL,OAAO;AAAA,MACP,SAAS;AAAA,QACP;AAAA,UACE,YAAY,EAAE,UAAU;AAAA,UACxB,QAAQ;AAAA,YACN;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,UACF;AAAA,QACF;AAAA,MACF;AAAA,IACF,CAAC;AACD,QAAI,gBAAgB,QAAQ,YAAY,SAAS,GAAG;AAClD,oBAAc,OAAO,QAAQ,WAAW;AAAA,IAC1C,WAAW,8BAA8B,GAAG;AAM1C,oBAAc,OAAO,QAAQ,CAAC,CAAC;AAAA,IACjC,OAAO;AACL,YAAM,gBAAgB,IAAI,kBAAkB,MAAM,CAAC;AACnD,YAAM,KAAK,gEAAgE;AAC3E,aAAO,KAAK,eAAe,aAAa;AAAA,IAC1C;AAAA,EACF;AACA,MAAI,OAAO,KAAK,UAAU,OAAO,KAAK,OAAO,QAAQ;AAEnD,UAAM,mBAAmB,wBAAwB,OAAO,KAAK,MAAM;AACnE,UAAM,eAAe,iBAAiB,IAAI,MAAM,GAAG,EAAE,KAAK,GAAG;AAC7D,UAAM,KAAK,cAAc,YAAY,GAAG;AACxC,WAAO,KAAK,GAAG,gBAAgB;AAAA,EACjC;AACA,MAAI,OAAO,KAAK,eAAe,OAAO,KAAK,YAAY,QAAQ;AAC7D,UAAM,eAAe,OAAO,KAAK,YAAY,IAAI,MAAM,GAAG,EAAE,KAAK,GAAG;AACpE,UAAM,KAAK,qBAAqB,YAAY,GAAG;AAC/C,WAAO,KAAK,GAAG,OAAO,KAAK,WAAW;AAAA,EACxC;AACA,MAAI,OAAO,KAAK,qBAAqB;AACnC,UAAM,KAAK,wBAAwB;AACnC,WAAO,KAAK,OAAO,KAAK,mBAAmB;AAAA,EAC7C;AACA,MAAI,OAAO,KAAK,mBAAmB;AACjC,UAAM,KAAK,wBAAwB;AACnC,WAAO,KAAK,OAAO,KAAK,iBAAiB;AAAA,EAC3C;AACA,MAAI,OAAO,KAAK,WAAW;AAGzB,UAAM,kBAAkB,CAAC,CAAC,OAAO,KAAK,QAAQ;AAC9C,QAAI,iBAAiB;AACnB,YAAM,KAAK,kCAAkC;AAAA,IAC/C,OAAO;AACL,YAAM,KAAK,sDAAsD;AAAA,IACnE;AAAA,EACF;AACA,MAAI,OAAO,KAAK,SAAS;AAIvB,UAAM,WAAW,MAAM,kBAAkB,IAAkC,aAAa,CAAC,GAAG,iBAAiB;AAC7G,kBAAc,OAAO,QAAQ,QAAQ;AAAA,EACvC;AACA,MAAI,OAAO,KAAK,YAAY,OAAO,KAAK,SAAS,QAAQ;AACvD,UAAM,eAAe,OAAO,KAAK,SAAS,IAAI,MAAM,GAAG,EAAE,KAAK,GAAG;AACjE,UAAM,KAAK,kHAAkH,YAAY,IAAI;AAC7I,WAAO,KAAK,GAAG,OAAO,KAAK,QAAQ;AAAA,EACrC;AACA,MAAI,OAAO,KAAK,aAAa,OAAO,KAAK,UAAU,QAAQ;AACzD,UAAM,eAAe,OAAO,KAAK,UAAU,IAAI,MAAM,GAAG,EAAE,KAAK,GAAG;AAClE,UAAM,KAAK,sHAAsH,YAAY,IAAI;AACjJ,WAAO,KAAK,GAAG,OAAO,KAAK,SAAS;AAAA,EACtC;AAEA,QAAM,WAAW,MAAM,KAAK,OAAO;AAGnC,QAAM,OAAO,MAAM,GAAG,cAAc,EAAE;AAAA,IASpC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAOU,QAAQ;AAAA;AAAA,IAElB;AAAA,EACF;AAGA,QAAM,WAAW,oBAAI,IAA4B;AACjD,aAAW,OAAO,MAAM;AACtB,UAAM,UAAU,IAAI,YAAY;AAChC,UAAM,QAAQ,OAAO,IAAI,SAAS,CAAC;AACnC,UAAM,QAAQ,OAAO,IAAI,SAAS,CAAC;AACnC,UAAM,YAAY,OAAO,IAAI,cAAc,CAAC;AAC5C,UAAM,YAAY,IAAI,YAAY,IAAI,SAAS,EAAE,KAAK;AAEtD,QAAI,CAAC,SAAS,IAAI,OAAO,GAAG;AAC1B,eAAS,IAAI,SAAS;AAAA,QACpB;AAAA,QACA,OAAO;AAAA,QACP,WAAW;AAAA,QACX,qBAAqB;AAAA,QACrB,YAAY,CAAC;AAAA,QACb,cAAc;AAAA,QACd,uBAAuB,CAAC;AAAA,MAC1B,CAAC;AAAA,IACH;AACA,UAAM,MAAM,SAAS,IAAI,OAAO;AAChC,QAAI,SAAS;AACb,QAAI,aAAa;AAKjB,QAAI,SAAS,SAAS,GAAG;AACvB,UAAI,WAAW,KAAK,EAAE,UAAU,OAAO,MAAM,CAAC;AAAA,IAChD;AAAA,EACF;AAGA,MAAI,kBAAkB;AACpB,UAAM,WAAW,UAAU,QAAQ,qBAAqB;AACxD,UAAM,QAAQ,oBAAI,KAAK;AACvB,UAAM,qBAAqB,oBAAI,IAAY;AAC3C,eAAW,OAAO,SAAS,OAAO,GAAG;AACnC,iBAAW,OAAO,IAAI,YAAY;AAChC,YAAI,IAAI,YAAY,IAAI,aAAa,kBAAkB;AACrD,6BAAmB,IAAI,IAAI,QAAQ;AAAA,QACrC;AAAA,MACF;AAAA,IACF;AAEA,UAAM,YAAY,oBAAI,IAAoB;AAC1C,QAAI,YAAY,mBAAmB,OAAO,GAAG;AAC3C,YAAM,QAAQ,MAAM,KAAK,kBAAkB,EAAE,IAAI,CAAC,OAAO;AAAA,QACvD,kBAAkB;AAAA,QAClB,gBAAgB;AAAA,MAClB,EAAE;AACF,UAAI;AACF,cAAM,UAAU,MAAM,SAAS,SAAS;AAAA,UACtC;AAAA,UACA,MAAM;AAAA,UACN,OAAO,EAAE,UAAU,mBAAmB,gBAAgB,aAAa,CAAC,EAAE;AAAA,UACtE,SAAS,EAAE,aAAa,IAAI,WAAW,MAAM;AAAA,QAC/C,CAAC;AACD,mBAAW,CAAC,KAAK,UAAU,KAAK,SAAS;AACvC,cAAI,WAAW,MAAM,SAAS,GAAG;AAE/B,kBAAM,OAAO,OAAO,WAAW,MAAM,CAAC,EAAE,IAAI;AAC5C,gBAAI,OAAO,SAAS,IAAI,KAAK,OAAO,GAAG;AACrC,wBAAU,IAAI,KAAK,IAAI;AAAA,YACzB;AAAA,UACF;AAAA,QACF;AAAA,MACF,SAAS,KAAK;AAIZ,eAAO,KAAK,oEAAoE,EAAE,WAAW,mBAAmB,IAAI,CAAC;AAAA,MACvH;AAAA,IACF;AAEA,eAAW,OAAO,SAAS,OAAO,GAAG;AACnC,UAAI,YAAY;AAChB,UAAI,eAAe;AACnB,YAAM,wBAAkC,CAAC;AACzC,iBAAW,OAAO,IAAI,YAAY;AAChC,YAAI,CAAC,IAAI,SAAU;AACnB,YAAI,IAAI,aAAa,kBAAkB;AACrC,uBAAa,IAAI;AACjB;AAAA,QACF;AACA,cAAM,MAAM,GAAG,IAAI,QAAQ,IAAI,gBAAgB;AAC/C,cAAM,OAAO,UAAU,IAAI,GAAG;AAC9B,YAAI,SAAS,QAAW;AACtB,uBAAa,IAAI,QAAQ;AAAA,QAC3B,OAAO;AACL,yBAAe;AACf,cAAI,CAAC,sBAAsB,SAAS,IAAI,QAAQ,GAAG;AACjD,kCAAsB,KAAK,IAAI,QAAQ;AAAA,UACzC;AAAA,QACF;AAAA,MACF;AAIA,UAAI,sBAAsB,KAAK,MAAM,SAAS;AAC9C,UAAI,eAAe;AACnB,UAAI,wBAAwB;AAAA,IAC9B;AAAA,EACF,OAAO;AAIL,eAAW,OAAO,SAAS,OAAO,GAAG;AACnC,YAAM,UAAU,IAAI,WAAW,IAAI,CAAC,QAAQ,IAAI,QAAQ,EAAE,OAAO,CAAC,MAAmB,CAAC,CAAC,CAAC;AACxF,UAAI,QAAQ,SAAS,GAAG;AACtB,YAAI,eAAe;AACnB,YAAI,wBAAwB,MAAM,KAAK,IAAI,IAAI,OAAO,CAAC;AAAA,MACzD;AAAA,IACF;AAAA,EACF;AAGA,aAAW,OAAO,SAAS,OAAO,GAAG;AACnC,QAAI,WAAW,KAAK,CAAC,GAAG,MAAM,EAAE,QAAQ,EAAE,KAAK;AAAA,EACjD;AAEA,QAAM,WAA8B;AAAA,IAClC;AAAA,IACA,UAAU,MAAM,KAAK,SAAS,OAAO,CAAC;AAAA,EACxC;AAEA,SAAO,aAAa,KAAK,QAAQ;AACnC;",
|
|
4
|
+
"sourcesContent": ["import { createHash } from 'node:crypto'\nimport { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager as CoreEntityManager } from '@mikro-orm/core'\nimport type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveDealsOrganizationIds } from '../../../lib/dealsOrganizationScope'\nimport { resolveOptionalBaseCurrencyCode } from '../../../lib/optionalBaseCurrency'\nimport type { ExchangeRateService } from '@open-mercato/core/modules/currencies/services/exchangeRateService'\nimport { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport {\n buildCollectionTags,\n debugCrudCache,\n isCrudCacheEnabled,\n resolveCrudCache,\n} from '@open-mercato/shared/lib/crud/cache'\nimport { readQueryParamList } from '@open-mercato/shared/lib/crud/query-params'\nimport { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { fetchStuckDealIds } from '../../../lib/stuckDeals'\nimport { expandDealStatusAliases } from '../../../lib/dealStatus'\nimport { findMatchingEntityIdsBySearchTokensAcrossSources } from '../../utils'\nimport { E } from '#generated/entities.ids.generated'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\nconst DEALS_AGGREGATE_CACHE_RESOURCE = 'customers.deal'\nconst DEALS_AGGREGATE_CACHE_TTL_MS = 30_000\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.deals.view'] },\n}\n\nconst querySchema = z.object({\n pipelineId: z.string().uuid().optional(),\n search: z.string().optional(),\n status: z.array(z.string().max(50)).max(20).optional(),\n ownerUserId: z.array(z.string().uuid()).optional(),\n personId: z.array(z.string().uuid()).optional(),\n companyId: z.array(z.string().uuid()).optional(),\n isStuck: z.preprocess((value) => {\n const parsed = parseBooleanFromUnknown(value)\n return parsed === null ? value : parsed\n }, z.boolean()).optional(),\n isOverdue: z.preprocess((value) => {\n const parsed = parseBooleanFromUnknown(value)\n return parsed === null ? value : parsed\n }, z.boolean()).optional(),\n expectedCloseAtFrom: z.string().optional(),\n expectedCloseAtTo: z.string().optional(),\n})\n\ntype AggregateQuery = z.infer<typeof querySchema>\n\nfunction normalizeStringSet(values: string[] | undefined): string[] {\n return Array.from(new Set((values ?? []).map((value) => value.trim())))\n .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))\n}\n\nfunction buildDealsAggregateCacheKey(params: {\n tenantId: string\n currencyScopeOrganizationId: string | null\n organizationIds: string[]\n query: AggregateQuery\n}): string {\n const signature = {\n tenantId: params.tenantId,\n currencyScopeOrganizationId: params.currencyScopeOrganizationId,\n organizationIds: normalizeStringSet(params.organizationIds),\n pipelineId: params.query.pipelineId ?? null,\n status: normalizeStringSet(params.query.status),\n ownerUserId: normalizeStringSet(params.query.ownerUserId),\n personId: normalizeStringSet(params.query.personId),\n companyId: normalizeStringSet(params.query.companyId),\n expectedCloseAtFrom: params.query.expectedCloseAtFrom ?? null,\n expectedCloseAtTo: params.query.expectedCloseAtTo ?? null,\n isOverdue: params.query.isOverdue === true,\n }\n const digest = createHash('sha256').update(JSON.stringify(signature)).digest('hex')\n return `customers:deal:aggregate:v1:${digest}`\n}\n\nfunction isDealsAggregateCacheEligible(searchParams: URLSearchParams): boolean {\n return !searchParams.has('search') && !searchParams.has('isStuck')\n}\n\ntype StageBreakdownByCurrency = {\n currency: string\n total: number\n count: number\n}\n\ntype StageAggregate = {\n stageId: string\n count: number\n openCount: number\n totalInBaseCurrency: number\n byCurrency: StageBreakdownByCurrency[]\n /**\n * `true` when every currency present in `byCurrency` was either the base currency or\n * had a usable exchange rate at request time. `false` when at least one currency in\n * `byCurrency` could not be resolved to base \u2014 the lane header should surface a\n * \"partial\" indicator in that case so the converted total isn't read as authoritative.\n */\n convertedAll: boolean\n /**\n * Currencies present in `byCurrency` that had NO exchange rate to the base currency.\n * Empty when conversion was complete or there is no base currency configured. The\n * client uses this to disclose which slice of the value is excluded from `totalInBaseCurrency`.\n */\n missingRateCurrencies: string[]\n}\n\ntype AggregateResponse = {\n baseCurrencyCode: string | null\n perStage: StageAggregate[]\n}\n\nconst stageBreakdownByCurrencySchema = z.object({\n currency: z.string(),\n total: z.number(),\n count: z.number(),\n})\n\nconst stageAggregateSchema = z.object({\n stageId: z.string(),\n count: z.number(),\n openCount: z.number(),\n totalInBaseCurrency: z.number(),\n byCurrency: z.array(stageBreakdownByCurrencySchema),\n convertedAll: z.boolean(),\n missingRateCurrencies: z.array(z.string()),\n})\n\nconst aggregateResponseSchema = z.object({\n baseCurrencyCode: z.string().nullable(),\n perStage: z.array(stageAggregateSchema),\n})\n\nconst aggregateErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'Deals aggregate per pipeline stage',\n methods: {\n GET: {\n summary: 'Per-stage counts and currency totals for kanban lane headers',\n description:\n 'Returns per-stage counts and totals for deals, with values converted to the tenant base currency where rates are available. Used to power kanban lane headers without loading every deal.',\n query: querySchema,\n responses: [\n { status: 200, description: 'Per-stage aggregate payload', schema: aggregateResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid query parameters', schema: aggregateErrorSchema },\n { status: 401, description: 'Unauthorized', schema: aggregateErrorSchema },\n ],\n },\n },\n}\n\nfunction readArrayParam(searchParams: URLSearchParams, key: string): string[] | null {\n const values = readQueryParamList(searchParams, key)\n return values.length ? values : null\n}\n\nfunction restrictToIds(where: string[], values: Array<string | number | null>, ids: string[]) {\n if (ids.length === 0) {\n where.push('id = ?')\n values.push('00000000-0000-0000-0000-000000000000')\n return\n }\n const placeholders = ids.map(() => '?').join(',')\n where.push(`id IN (${placeholders})`)\n values.push(...ids)\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const url = new URL(req.url)\n const params = url.searchParams\n const parsed = querySchema.safeParse({\n pipelineId: params.get('pipelineId') ?? undefined,\n search: params.get('search') ?? undefined,\n status: readArrayParam(params, 'status') ?? undefined,\n ownerUserId: readArrayParam(params, 'ownerUserId') ?? undefined,\n personId: readArrayParam(params, 'personId') ?? undefined,\n companyId: readArrayParam(params, 'companyId') ?? undefined,\n isStuck: params.get('isStuck') ?? undefined,\n isOverdue: params.get('isOverdue') ?? undefined,\n expectedCloseAtFrom: params.get('expectedCloseAtFrom') ?? undefined,\n expectedCloseAtTo: params.get('expectedCloseAtTo') ?? undefined,\n })\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })\n }\n\n const container = await createRequestContainer()\n const em = container.resolve<CoreEntityManager>('em')\n\n // Resolve organization scope from the request so multi-org operators can aggregate\n // deals for any org their RBAC scope permits (matching the deal detail route at\n // [id]/route.ts:360). Falls back to `auth.orgId` when no scope cookie is set or\n // when rbacService cannot be resolved (e.g. in unit tests with a minimal container).\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const effectiveTenantId = scope.tenantId ?? auth.tenantId\n if (!effectiveTenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const orgFilterIds = await resolveDealsOrganizationIds({ em, scope, auth, tenantId: effectiveTenantId })\n const aggregateCache = isDealsAggregateCacheEligible(params) && isCrudCacheEnabled()\n ? resolveCrudCache(container)\n : null\n const aggregateCacheKey = aggregateCache\n ? buildDealsAggregateCacheKey({\n tenantId: effectiveTenantId,\n currencyScopeOrganizationId: orgFilterIds[0] ?? null,\n organizationIds: orgFilterIds,\n query: parsed.data,\n })\n : null\n\n if (aggregateCache && aggregateCacheKey) {\n try {\n const cached = await runWithCacheTenant(\n effectiveTenantId,\n () => aggregateCache.get(aggregateCacheKey),\n )\n const cachedResponse = aggregateResponseSchema.safeParse(cached)\n if (cachedResponse.success) {\n return NextResponse.json(cachedResponse.data)\n }\n if (cached !== null && cached !== undefined) {\n debugCrudCache('get-invalid', {\n resource: DEALS_AGGREGATE_CACHE_RESOURCE,\n key: aggregateCacheKey,\n })\n }\n } catch (err) {\n debugCrudCache('get', {\n resource: DEALS_AGGREGATE_CACHE_RESOURCE,\n key: aggregateCacheKey,\n error: err instanceof Error ? err.message : String(err),\n })\n }\n }\n\n // Raw SQL is used here intentionally \u2014 the route only projects non-encrypted columns\n // (`pipeline_stage_id`, `value_amount`, `value_currency`, `status`, plus filters). It\n // avoids the per-row decryption cost that would be paid by `findWithDecryption` for an\n // aggregate that never reads `title`/`description`. The search path still relies on\n // the token index above to find matching deals when encrypted columns are involved.\n const baseCurrencyCode = await resolveOptionalBaseCurrencyCode(\n container,\n effectiveTenantId,\n orgFilterIds[0],\n )\n\n // Build WHERE clause shared between count + sum queries\n const orgPlaceholders = orgFilterIds.map(() => '?').join(',')\n const where: string[] = [\n 'tenant_id = ?',\n `organization_id IN (${orgPlaceholders})`,\n 'deleted_at IS NULL',\n ]\n const values: Array<string | number | null> = [effectiveTenantId, ...orgFilterIds]\n\n if (parsed.data.pipelineId) {\n where.push('pipeline_id = ?')\n values.push(parsed.data.pipelineId)\n }\n const search = parsed.data.search?.trim()\n if (search) {\n const searchCtx: CrudCtx = {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: orgFilterIds[0],\n organizationIds: orgFilterIds,\n request: req,\n }\n const matchingIds = await findMatchingEntityIdsBySearchTokensAcrossSources({\n ctx: searchCtx,\n query: search,\n sources: [\n {\n entityType: E.customers.customer_deal,\n fields: [\n 'title',\n 'description',\n 'status',\n 'pipeline_stage',\n 'source',\n 'value_amount',\n 'value_currency',\n 'cf:competitive_risk',\n 'cf:implementation_complexity',\n ],\n },\n ],\n })\n if (matchingIds !== null && matchingIds.length > 0) {\n restrictToIds(where, values, matchingIds)\n } else if (isTenantDataEncryptionEnabled()) {\n // `customers:customer_deal.title` and `.description` are declared in\n // `encryption.ts` as encrypted columns. A raw `ILIKE` over the ciphertext would\n // silently match nothing on encrypted tenants and produce misleading lane totals,\n // so we collapse the result to zero rows instead \u2014 consistent with the list\n // endpoint's behavior when the token index has nothing to offer.\n restrictToIds(where, values, [])\n } else {\n const searchPattern = `%${escapeLikePattern(search)}%`\n where.push(\"(title ILIKE ? ESCAPE '\\\\' OR description ILIKE ? ESCAPE '\\\\')\")\n values.push(searchPattern, searchPattern)\n }\n }\n if (parsed.data.status && parsed.data.status.length) {\n // Non-empty input always expands to a non-empty set, so this always narrows.\n const expandedStatuses = expandDealStatusAliases(parsed.data.status)\n const placeholders = expandedStatuses.map(() => '?').join(',')\n where.push(`status IN (${placeholders})`)\n values.push(...expandedStatuses)\n }\n if (parsed.data.ownerUserId && parsed.data.ownerUserId.length) {\n const placeholders = parsed.data.ownerUserId.map(() => '?').join(',')\n where.push(`owner_user_id IN (${placeholders})`)\n values.push(...parsed.data.ownerUserId)\n }\n if (parsed.data.expectedCloseAtFrom) {\n where.push('expected_close_at >= ?')\n values.push(parsed.data.expectedCloseAtFrom)\n }\n if (parsed.data.expectedCloseAtTo) {\n where.push('expected_close_at <= ?')\n values.push(parsed.data.expectedCloseAtTo)\n }\n if (parsed.data.isOverdue) {\n // Mirror the list route's precedence: the caller-supplied status filter wins, and\n // status='open' is injected only when none was provided.\n const hasCallerStatus = !!parsed.data.status?.length\n if (hasCallerStatus) {\n where.push('expected_close_at < CURRENT_DATE')\n } else {\n where.push(\"expected_close_at < CURRENT_DATE AND status = 'open'\")\n }\n }\n if (parsed.data.isStuck) {\n // Reuse the list endpoint's stuck-deal lookup so kanban headers, lane counts, and the\n // cards rendered inside each lane agree on the same definition of \"stuck\". Empty result\n // \u2192 narrow to a sentinel UUID so the WHERE clause collapses to zero rows.\n const stuckIds = await fetchStuckDealIds(em as unknown as PgEntityManager, orgFilterIds[0], effectiveTenantId)\n restrictToIds(where, values, stuckIds)\n }\n if (parsed.data.personId && parsed.data.personId.length) {\n const placeholders = parsed.data.personId.map(() => '?').join(',')\n where.push(`EXISTS (SELECT 1 FROM customer_deal_people dp WHERE dp.deal_id = customer_deals.id AND dp.person_entity_id IN (${placeholders}))`)\n values.push(...parsed.data.personId)\n }\n if (parsed.data.companyId && parsed.data.companyId.length) {\n const placeholders = parsed.data.companyId.map(() => '?').join(',')\n where.push(`EXISTS (SELECT 1 FROM customer_deal_companies dc WHERE dc.deal_id = customer_deals.id AND dc.company_entity_id IN (${placeholders}))`)\n values.push(...parsed.data.companyId)\n }\n\n const whereSql = where.join(' AND ')\n\n // Aggregate by (stage, currency). Treat null stage as the literal `__unassigned`.\n const rows = await em.getConnection().execute<\n Array<{\n stage_id: string | null\n currency: string | null\n total: string | number | null\n count: string | number\n open_count: string | number\n }>\n >(\n `SELECT\n pipeline_stage_id AS stage_id,\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount), 0) AS total,\n COUNT(*) AS count,\n COUNT(*) FILTER (WHERE status = 'open') AS open_count\n FROM customer_deals\n WHERE ${whereSql}\n GROUP BY pipeline_stage_id, UPPER(COALESCE(value_currency, ''))`,\n values,\n )\n\n // Reduce to per-stage aggregates\n const stageMap = new Map<string, StageAggregate>()\n for (const row of rows) {\n const stageId = row.stage_id ?? '__unassigned'\n const total = Number(row.total ?? 0)\n const count = Number(row.count ?? 0)\n const openCount = Number(row.open_count ?? 0)\n const currency = (row.currency ?? '').toString().trim()\n\n if (!stageMap.has(stageId)) {\n stageMap.set(stageId, {\n stageId,\n count: 0,\n openCount: 0,\n totalInBaseCurrency: 0,\n byCurrency: [],\n convertedAll: true,\n missingRateCurrencies: [],\n })\n }\n const agg = stageMap.get(stageId)!\n agg.count += count\n agg.openCount += openCount\n // Include any row that has a real currency code, even when its summed amount is\n // zero \u2014 `byCurrency` should agree with `count` so the per-currency breakdown\n // doesn't silently drop deals whose amount happens to be zero. Rows with no\n // currency code at all still get folded into the stage totals via `count`.\n if (currency.length > 0) {\n agg.byCurrency.push({ currency, total, count })\n }\n }\n\n // Convert to base currency where possible\n if (baseCurrencyCode) {\n const exchange = container.resolve('exchangeRateService') as ExchangeRateService | undefined\n const today = new Date()\n const distinctCurrencies = new Set<string>()\n for (const agg of stageMap.values()) {\n for (const row of agg.byCurrency) {\n if (row.currency && row.currency !== baseCurrencyCode) {\n distinctCurrencies.add(row.currency)\n }\n }\n }\n\n const rateCache = new Map<string, number>()\n if (exchange && distinctCurrencies.size > 0) {\n const pairs = Array.from(distinctCurrencies).map((c) => ({\n fromCurrencyCode: c,\n toCurrencyCode: baseCurrencyCode,\n }))\n try {\n const results = await exchange.getRates({\n pairs,\n date: today,\n scope: { tenantId: effectiveTenantId, organizationId: orgFilterIds[0] },\n options: { maxDaysBack: 60, autoFetch: false },\n })\n for (const [key, rateResult] of results) {\n if (rateResult.rates.length > 0) {\n // Pick the first matching rate (sources are equivalent for display purposes)\n const rate = Number(rateResult.rates[0].rate)\n if (Number.isFinite(rate) && rate > 0) {\n rateCache.set(key, rate)\n }\n }\n }\n } catch (err) {\n // Swallow \u2014 partial totals are still useful and we'll fall back to currency-native\n // sums. Logging at warn level so operators can correlate missing-rate disclosures in\n // the UI (`convertedAll: false` / `missingRateCurrencies`) with the underlying error.\n logger.warn('exchange-rate lookup failed; falling back to per-currency totals', { component: 'deals.aggregate', err })\n }\n }\n\n for (const agg of stageMap.values()) {\n let totalBase = 0\n let convertedAll = true\n const missingRateCurrencies: string[] = []\n for (const row of agg.byCurrency) {\n if (!row.currency) continue\n if (row.currency === baseCurrencyCode) {\n totalBase += row.total\n continue\n }\n const key = `${row.currency}/${baseCurrencyCode}`\n const rate = rateCache.get(key)\n if (rate !== undefined) {\n totalBase += row.total * rate\n } else {\n convertedAll = false\n if (!missingRateCurrencies.includes(row.currency)) {\n missingRateCurrencies.push(row.currency)\n }\n }\n }\n // Even when some rates are missing, totalInBaseCurrency reflects the converted slice.\n // `convertedAll`/`missingRateCurrencies` let the client distinguish a complete\n // conversion from a partial one and disclose which currencies were excluded.\n agg.totalInBaseCurrency = Math.round(totalBase)\n agg.convertedAll = convertedAll\n agg.missingRateCurrencies = missingRateCurrencies\n }\n } else {\n // No base currency configured for the tenant \u2014 surface this by marking every stage as\n // \"not converted\" with all currencies flagged as missing rates. This way the client UI\n // can fall back to a per-currency breakdown without trying to render a board total.\n for (const agg of stageMap.values()) {\n const present = agg.byCurrency.map((row) => row.currency).filter((c): c is string => !!c)\n if (present.length > 0) {\n agg.convertedAll = false\n agg.missingRateCurrencies = Array.from(new Set(present))\n }\n }\n }\n\n // Sort byCurrency rows by total descending for stable display\n for (const agg of stageMap.values()) {\n agg.byCurrency.sort((a, b) => b.total - a.total)\n }\n\n const response: AggregateResponse = {\n baseCurrencyCode,\n perStage: Array.from(stageMap.values()),\n }\n\n if (aggregateCache && aggregateCacheKey) {\n try {\n await runWithCacheTenant(\n effectiveTenantId,\n () => aggregateCache.set(aggregateCacheKey, response, {\n ttl: DEALS_AGGREGATE_CACHE_TTL_MS,\n tags: buildCollectionTags(\n DEALS_AGGREGATE_CACHE_RESOURCE,\n effectiveTenantId,\n normalizeStringSet(orgFilterIds),\n ),\n }),\n )\n } catch (err) {\n debugCrudCache('store', {\n resource: DEALS_AGGREGATE_CACHE_RESOURCE,\n key: aggregateCacheKey,\n error: err instanceof Error ? err.message : String(err),\n })\n }\n }\n\n return NextResponse.json(response)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -5,31 +5,6 @@ import { updateCrud } from "@open-mercato/ui/backend/utils/crud";
|
|
|
5
5
|
import { readApiResultOrThrow, withScopedApiRequestHeaders } from "@open-mercato/ui/backend/utils/apiCall";
|
|
6
6
|
import { buildOptimisticLockHeader } from "@open-mercato/ui/backend/utils/optimisticLock";
|
|
7
7
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
8
|
-
function normalizePersonAssociationRecord(record, fallbackId) {
|
|
9
|
-
const displayName = typeof record.displayName === "string" && record.displayName.trim().length ? record.displayName.trim() : typeof record.display_name === "string" && record.display_name.trim().length ? record.display_name.trim() : null;
|
|
10
|
-
const email = typeof record.primaryEmail === "string" && record.primaryEmail.trim().length ? record.primaryEmail.trim() : typeof record.primary_email === "string" && record.primary_email.trim().length ? record.primary_email.trim() : null;
|
|
11
|
-
const phone = typeof record.primaryPhone === "string" && record.primaryPhone.trim().length ? record.primaryPhone.trim() : typeof record.primary_phone === "string" && record.primary_phone.trim().length ? record.primary_phone.trim() : null;
|
|
12
|
-
const profile = record.personProfile ?? record.person_profile ?? null;
|
|
13
|
-
const jobTitle = profile && typeof profile.jobTitle === "string" && profile.jobTitle.trim().length ? profile.jobTitle.trim() : null;
|
|
14
|
-
return {
|
|
15
|
-
id: typeof record.id === "string" ? record.id : fallbackId,
|
|
16
|
-
label: displayName ?? email ?? phone ?? fallbackId,
|
|
17
|
-
subtitle: jobTitle ?? email ?? phone ?? null,
|
|
18
|
-
kind: "person"
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
function normalizeCompanyAssociationRecord(record, fallbackId) {
|
|
22
|
-
const displayName = typeof record.displayName === "string" && record.displayName.trim().length ? record.displayName.trim() : typeof record.display_name === "string" && record.display_name.trim().length ? record.display_name.trim() : null;
|
|
23
|
-
const profile = record.companyProfile ?? record.company_profile ?? null;
|
|
24
|
-
const domain = typeof record.domain === "string" && record.domain.trim().length ? record.domain.trim() : profile && typeof profile.domain === "string" && profile.domain.trim().length ? profile.domain.trim() : null;
|
|
25
|
-
const website = typeof record.websiteUrl === "string" && record.websiteUrl.trim().length ? record.websiteUrl.trim() : typeof record.website_url === "string" && record.website_url.trim().length ? record.website_url.trim() : profile && typeof profile.websiteUrl === "string" && profile.websiteUrl.trim().length ? profile.websiteUrl.trim() : null;
|
|
26
|
-
return {
|
|
27
|
-
id: typeof record.id === "string" ? record.id : fallbackId,
|
|
28
|
-
label: displayName ?? domain ?? website ?? fallbackId,
|
|
29
|
-
subtitle: domain ?? website ?? null,
|
|
30
|
-
kind: "company"
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
8
|
function sameIdList(left, right) {
|
|
34
9
|
if (left.length !== right.length) return false;
|
|
35
10
|
return left.every((value, index) => value === right[index]);
|
|
@@ -50,74 +25,6 @@ function useDealAssociations({
|
|
|
50
25
|
setPeopleEditorIds(data?.linkedPersonIds ?? []);
|
|
51
26
|
setCompaniesEditorIds(data?.linkedCompanyIds ?? []);
|
|
52
27
|
}, [data?.linkedCompanyIds, data?.linkedPersonIds]);
|
|
53
|
-
const loadPeopleAssociations = React.useCallback(async (ids) => {
|
|
54
|
-
const uniqueIds = Array.from(new Set(ids.map((value) => value.trim()).filter(Boolean)));
|
|
55
|
-
if (!uniqueIds.length) return [];
|
|
56
|
-
try {
|
|
57
|
-
const params = new URLSearchParams({
|
|
58
|
-
ids: uniqueIds.join(","),
|
|
59
|
-
pageSize: String(Math.max(uniqueIds.length, 1))
|
|
60
|
-
});
|
|
61
|
-
const payload = await readApiResultOrThrow(
|
|
62
|
-
`/api/customers/people?${params.toString()}`
|
|
63
|
-
);
|
|
64
|
-
const items = Array.isArray(payload.items) ? payload.items : [];
|
|
65
|
-
const byId = /* @__PURE__ */ new Map();
|
|
66
|
-
items.forEach((record) => {
|
|
67
|
-
if (record && typeof record.id === "string") byId.set(record.id, record);
|
|
68
|
-
});
|
|
69
|
-
return uniqueIds.map((personId) => {
|
|
70
|
-
const record = byId.get(personId);
|
|
71
|
-
return record ? normalizePersonAssociationRecord(record, personId) : {
|
|
72
|
-
id: personId,
|
|
73
|
-
label: personId,
|
|
74
|
-
subtitle: null,
|
|
75
|
-
kind: "person"
|
|
76
|
-
};
|
|
77
|
-
});
|
|
78
|
-
} catch {
|
|
79
|
-
return uniqueIds.map((personId) => ({
|
|
80
|
-
id: personId,
|
|
81
|
-
label: personId,
|
|
82
|
-
subtitle: null,
|
|
83
|
-
kind: "person"
|
|
84
|
-
}));
|
|
85
|
-
}
|
|
86
|
-
}, []);
|
|
87
|
-
const loadCompanyAssociations = React.useCallback(async (ids) => {
|
|
88
|
-
const uniqueIds = Array.from(new Set(ids.map((value) => value.trim()).filter(Boolean)));
|
|
89
|
-
if (!uniqueIds.length) return [];
|
|
90
|
-
try {
|
|
91
|
-
const params = new URLSearchParams({
|
|
92
|
-
ids: uniqueIds.join(","),
|
|
93
|
-
pageSize: String(Math.max(uniqueIds.length, 1))
|
|
94
|
-
});
|
|
95
|
-
const payload = await readApiResultOrThrow(
|
|
96
|
-
`/api/customers/companies?${params.toString()}`
|
|
97
|
-
);
|
|
98
|
-
const items = Array.isArray(payload.items) ? payload.items : [];
|
|
99
|
-
const byId = /* @__PURE__ */ new Map();
|
|
100
|
-
items.forEach((record) => {
|
|
101
|
-
if (record && typeof record.id === "string") byId.set(record.id, record);
|
|
102
|
-
});
|
|
103
|
-
return uniqueIds.map((companyId) => {
|
|
104
|
-
const record = byId.get(companyId);
|
|
105
|
-
return record ? normalizeCompanyAssociationRecord(record, companyId) : {
|
|
106
|
-
id: companyId,
|
|
107
|
-
label: companyId,
|
|
108
|
-
subtitle: null,
|
|
109
|
-
kind: "company"
|
|
110
|
-
};
|
|
111
|
-
});
|
|
112
|
-
} catch {
|
|
113
|
-
return uniqueIds.map((companyId) => ({
|
|
114
|
-
id: companyId,
|
|
115
|
-
label: companyId,
|
|
116
|
-
subtitle: null,
|
|
117
|
-
kind: "company"
|
|
118
|
-
}));
|
|
119
|
-
}
|
|
120
|
-
}, []);
|
|
121
28
|
const loadLinkedPeoplePage = React.useCallback(
|
|
122
29
|
async (page, query) => {
|
|
123
30
|
if (!currentDealId) {
|
|
@@ -178,15 +85,7 @@ function useDealAssociations({
|
|
|
178
85
|
),
|
|
179
86
|
{ id: currentDealId, personIds: nextIds, operation: "updateDealPeople" }
|
|
180
87
|
);
|
|
181
|
-
|
|
182
|
-
setData(
|
|
183
|
-
(prev) => prev ? {
|
|
184
|
-
...prev,
|
|
185
|
-
people: nextPeople,
|
|
186
|
-
linkedPersonIds: nextIds,
|
|
187
|
-
counts: { ...prev.counts, people: nextIds.length }
|
|
188
|
-
} : prev
|
|
189
|
-
);
|
|
88
|
+
await onRefresh();
|
|
190
89
|
} catch (error) {
|
|
191
90
|
setPeopleEditorIds(previousIds);
|
|
192
91
|
setData(
|
|
@@ -197,14 +96,14 @@ function useDealAssociations({
|
|
|
197
96
|
counts: { ...prev.counts, people: previousIds.length }
|
|
198
97
|
} : prev
|
|
199
98
|
);
|
|
200
|
-
if (!surfaceRecordConflict(error, t, { onRefresh
|
|
99
|
+
if (!surfaceRecordConflict(error, t, { onRefresh })) {
|
|
201
100
|
flash(t("customers.deals.detail.peopleUpdateError", "Failed to update linked people."), "error");
|
|
202
101
|
}
|
|
203
102
|
} finally {
|
|
204
103
|
setPeopleSaving(false);
|
|
205
104
|
}
|
|
206
105
|
},
|
|
207
|
-
[currentDealId, data?.deal.updatedAt, data?.people,
|
|
106
|
+
[currentDealId, data?.deal.updatedAt, data?.people, onRefresh, peopleEditorIds, runMutationWithContext, setData, t]
|
|
208
107
|
);
|
|
209
108
|
const handleCompaniesAssociationsChange = React.useCallback(
|
|
210
109
|
async (nextIds) => {
|
|
@@ -222,15 +121,7 @@ function useDealAssociations({
|
|
|
222
121
|
),
|
|
223
122
|
{ id: currentDealId, companyIds: nextIds, operation: "updateDealCompanies" }
|
|
224
123
|
);
|
|
225
|
-
|
|
226
|
-
setData(
|
|
227
|
-
(prev) => prev ? {
|
|
228
|
-
...prev,
|
|
229
|
-
companies: nextCompanies,
|
|
230
|
-
linkedCompanyIds: nextIds,
|
|
231
|
-
counts: { ...prev.counts, companies: nextIds.length }
|
|
232
|
-
} : prev
|
|
233
|
-
);
|
|
124
|
+
await onRefresh();
|
|
234
125
|
} catch (error) {
|
|
235
126
|
setCompaniesEditorIds(previousIds);
|
|
236
127
|
setData(
|
|
@@ -241,7 +132,7 @@ function useDealAssociations({
|
|
|
241
132
|
counts: { ...prev.counts, companies: previousIds.length }
|
|
242
133
|
} : prev
|
|
243
134
|
);
|
|
244
|
-
if (!surfaceRecordConflict(error, t, { onRefresh
|
|
135
|
+
if (!surfaceRecordConflict(error, t, { onRefresh })) {
|
|
245
136
|
flash(t("customers.deals.detail.companiesUpdateError", "Failed to update linked companies."), "error");
|
|
246
137
|
}
|
|
247
138
|
} finally {
|
|
@@ -253,7 +144,6 @@ function useDealAssociations({
|
|
|
253
144
|
currentDealId,
|
|
254
145
|
data?.companies,
|
|
255
146
|
data?.deal.updatedAt,
|
|
256
|
-
loadCompanyAssociations,
|
|
257
147
|
onRefresh,
|
|
258
148
|
runMutationWithContext,
|
|
259
149
|
setData,
|
|
@@ -272,8 +162,6 @@ function useDealAssociations({
|
|
|
272
162
|
};
|
|
273
163
|
}
|
|
274
164
|
export {
|
|
275
|
-
normalizeCompanyAssociationRecord,
|
|
276
|
-
normalizePersonAssociationRecord,
|
|
277
165
|
useDealAssociations
|
|
278
166
|
};
|
|
279
167
|
//# sourceMappingURL=useDealAssociations.js.map
|
package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../src/modules/customers/backend/customers/deals/%5Bid%5D/hooks/useDealAssociations.ts"],
|
|
4
|
-
"sourcesContent": ["import * as React from 'react'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { updateCrud } from '@open-mercato/ui/backend/utils/crud'\nimport { readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type {\n CompanyAssociationApiRecord,\n DealAssociation,\n DealDetailPayload,\n GuardedMutationRunner,\n PersonAssociationApiRecord,\n} from './types'\n\nexport function normalizePersonAssociationRecord(\n record: PersonAssociationApiRecord,\n fallbackId: string,\n): DealAssociation {\n const displayName =\n typeof record.displayName === 'string' && record.displayName.trim().length\n ? record.displayName.trim()\n : typeof record.display_name === 'string' && record.display_name.trim().length\n ? record.display_name.trim()\n : null\n const email =\n typeof record.primaryEmail === 'string' && record.primaryEmail.trim().length\n ? record.primaryEmail.trim()\n : typeof record.primary_email === 'string' && record.primary_email.trim().length\n ? record.primary_email.trim()\n : null\n const phone =\n typeof record.primaryPhone === 'string' && record.primaryPhone.trim().length\n ? record.primaryPhone.trim()\n : typeof record.primary_phone === 'string' && record.primary_phone.trim().length\n ? record.primary_phone.trim()\n : null\n const profile = record.personProfile ?? record.person_profile ?? null\n const jobTitle =\n profile && typeof profile.jobTitle === 'string' && profile.jobTitle.trim().length\n ? profile.jobTitle.trim()\n : null\n return {\n id: typeof record.id === 'string' ? record.id : fallbackId,\n label: displayName ?? email ?? phone ?? fallbackId,\n subtitle: jobTitle ?? email ?? phone ?? null,\n kind: 'person',\n }\n}\n\nexport function normalizeCompanyAssociationRecord(\n record: CompanyAssociationApiRecord,\n fallbackId: string,\n): DealAssociation {\n const displayName =\n typeof record.displayName === 'string' && record.displayName.trim().length\n ? record.displayName.trim()\n : typeof record.display_name === 'string' && record.display_name.trim().length\n ? record.display_name.trim()\n : null\n const profile = record.companyProfile ?? record.company_profile ?? null\n const domain =\n typeof record.domain === 'string' && record.domain.trim().length\n ? record.domain.trim()\n : profile && typeof profile.domain === 'string' && profile.domain.trim().length\n ? profile.domain.trim()\n : null\n const website =\n typeof record.websiteUrl === 'string' && record.websiteUrl.trim().length\n ? record.websiteUrl.trim()\n : typeof record.website_url === 'string' && record.website_url.trim().length\n ? record.website_url.trim()\n : profile && typeof profile.websiteUrl === 'string' && profile.websiteUrl.trim().length\n ? profile.websiteUrl.trim()\n : null\n return {\n id: typeof record.id === 'string' ? record.id : fallbackId,\n label: displayName ?? domain ?? website ?? fallbackId,\n subtitle: domain ?? website ?? null,\n kind: 'company',\n }\n}\n\nfunction sameIdList(left: string[], right: string[]): boolean {\n if (left.length !== right.length) return false\n return left.every((value, index) => value === right[index])\n}\n\ntype LinkedPageResult = {\n items: DealAssociation[]\n totalPages: number\n total: number\n}\n\ntype UseDealAssociationsOptions = {\n currentDealId: string | null\n data: DealDetailPayload | null\n setData: React.Dispatch<React.SetStateAction<DealDetailPayload | null>>\n runMutationWithContext: GuardedMutationRunner\n /** Re-fetch the deal detail; wired into the conflict bar's refresh action on a 409. */\n onRefresh?: (() => void) | null\n}\n\ntype UseDealAssociationsResult = {\n peopleEditorIds: string[]\n companiesEditorIds: string[]\n peopleSaving: boolean\n companiesSaving: boolean\n handlePeopleAssociationsChange: (nextIds: string[]) => Promise<void>\n handleCompaniesAssociationsChange: (nextIds: string[]) => Promise<void>\n loadLinkedPeoplePage: (page: number, query: string) => Promise<LinkedPageResult>\n loadLinkedCompaniesPage: (page: number, query: string) => Promise<LinkedPageResult>\n}\n\nexport function useDealAssociations({\n currentDealId,\n data,\n setData,\n runMutationWithContext,\n onRefresh,\n}: UseDealAssociationsOptions): UseDealAssociationsResult {\n const t = useT()\n const [peopleEditorIds, setPeopleEditorIds] = React.useState<string[]>([])\n const [companiesEditorIds, setCompaniesEditorIds] = React.useState<string[]>([])\n const [peopleSaving, setPeopleSaving] = React.useState(false)\n const [companiesSaving, setCompaniesSaving] = React.useState(false)\n\n React.useEffect(() => {\n setPeopleEditorIds(data?.linkedPersonIds ?? [])\n setCompaniesEditorIds(data?.linkedCompanyIds ?? [])\n }, [data?.linkedCompanyIds, data?.linkedPersonIds])\n\n const loadPeopleAssociations = React.useCallback(async (ids: string[]): Promise<DealAssociation[]> => {\n const uniqueIds = Array.from(new Set(ids.map((value) => value.trim()).filter(Boolean)))\n if (!uniqueIds.length) return []\n try {\n const params = new URLSearchParams({\n ids: uniqueIds.join(','),\n pageSize: String(Math.max(uniqueIds.length, 1)),\n })\n const payload = await readApiResultOrThrow<{ items?: PersonAssociationApiRecord[] }>(\n `/api/customers/people?${params.toString()}`,\n )\n const items = Array.isArray(payload.items) ? payload.items : []\n const byId = new Map<string, PersonAssociationApiRecord>()\n items.forEach((record) => {\n if (record && typeof record.id === 'string') byId.set(record.id, record)\n })\n return uniqueIds.map((personId) => {\n const record = byId.get(personId)\n return record\n ? normalizePersonAssociationRecord(record, personId)\n : {\n id: personId,\n label: personId,\n subtitle: null,\n kind: 'person' as const,\n }\n })\n } catch {\n return uniqueIds.map((personId) => ({\n id: personId,\n label: personId,\n subtitle: null,\n kind: 'person' as const,\n }))\n }\n }, [])\n\n const loadCompanyAssociations = React.useCallback(async (ids: string[]): Promise<DealAssociation[]> => {\n const uniqueIds = Array.from(new Set(ids.map((value) => value.trim()).filter(Boolean)))\n if (!uniqueIds.length) return []\n try {\n const params = new URLSearchParams({\n ids: uniqueIds.join(','),\n pageSize: String(Math.max(uniqueIds.length, 1)),\n })\n const payload = await readApiResultOrThrow<{ items?: CompanyAssociationApiRecord[] }>(\n `/api/customers/companies?${params.toString()}`,\n )\n const items = Array.isArray(payload.items) ? payload.items : []\n const byId = new Map<string, CompanyAssociationApiRecord>()\n items.forEach((record) => {\n if (record && typeof record.id === 'string') byId.set(record.id, record)\n })\n return uniqueIds.map((companyId) => {\n const record = byId.get(companyId)\n return record\n ? normalizeCompanyAssociationRecord(record, companyId)\n : {\n id: companyId,\n label: companyId,\n subtitle: null,\n kind: 'company' as const,\n }\n })\n } catch {\n return uniqueIds.map((companyId) => ({\n id: companyId,\n label: companyId,\n subtitle: null,\n kind: 'company' as const,\n }))\n }\n }, [])\n\n const loadLinkedPeoplePage = React.useCallback(\n async (page: number, query: string): Promise<LinkedPageResult> => {\n if (!currentDealId) {\n return { items: [], totalPages: 1, total: 0 }\n }\n const params = new URLSearchParams({\n page: String(page),\n pageSize: '20',\n sort: 'name-asc',\n })\n if (query.trim().length > 0) {\n params.set('search', query.trim())\n }\n const payload = await readApiResultOrThrow<{\n items?: DealAssociation[]\n total?: number\n totalPages?: number\n }>(`/api/customers/deals/${encodeURIComponent(currentDealId)}/people?${params.toString()}`)\n return {\n items: Array.isArray(payload.items) ? payload.items : [],\n totalPages: typeof payload.totalPages === 'number' ? payload.totalPages : 1,\n total: typeof payload.total === 'number' ? payload.total : 0,\n }\n },\n [currentDealId],\n )\n\n const loadLinkedCompaniesPage = React.useCallback(\n async (page: number, query: string): Promise<LinkedPageResult> => {\n if (!currentDealId) {\n return { items: [], totalPages: 1, total: 0 }\n }\n const params = new URLSearchParams({\n page: String(page),\n pageSize: '20',\n sort: 'name-asc',\n })\n if (query.trim().length > 0) {\n params.set('search', query.trim())\n }\n const payload = await readApiResultOrThrow<{\n items?: DealAssociation[]\n total?: number\n totalPages?: number\n }>(`/api/customers/deals/${encodeURIComponent(currentDealId)}/companies?${params.toString()}`)\n return {\n items: Array.isArray(payload.items) ? payload.items : [],\n totalPages: typeof payload.totalPages === 'number' ? payload.totalPages : 1,\n total: typeof payload.total === 'number' ? payload.total : 0,\n }\n },\n [currentDealId],\n )\n\n const handlePeopleAssociationsChange = React.useCallback(\n async (nextIds: string[]) => {\n if (!currentDealId) return\n if (sameIdList(nextIds, peopleEditorIds)) return\n const previousIds = peopleEditorIds\n const previousPeople = data?.people ?? []\n setPeopleEditorIds(nextIds)\n setPeopleSaving(true)\n try {\n await runMutationWithContext(\n () => withScopedApiRequestHeaders(\n buildOptimisticLockHeader(data?.deal.updatedAt),\n () => updateCrud('customers/deals', { id: currentDealId, personIds: nextIds }),\n ),\n { id: currentDealId, personIds: nextIds, operation: 'updateDealPeople' },\n )\n const nextPeople = await loadPeopleAssociations(nextIds.slice(0, 3))\n setData((prev) =>\n prev\n ? {\n ...prev,\n people: nextPeople,\n linkedPersonIds: nextIds,\n counts: { ...prev.counts, people: nextIds.length },\n }\n : prev,\n )\n } catch (error) {\n setPeopleEditorIds(previousIds)\n setData((prev) =>\n prev\n ? {\n ...prev,\n people: previousPeople,\n linkedPersonIds: previousIds,\n counts: { ...prev.counts, people: previousIds.length },\n }\n : prev,\n )\n // runMutationWithContext already surfaces the conflict bar on a 409; only\n // fall back to the generic flash when this is not a record conflict.\n if (!surfaceRecordConflict(error, t, { onRefresh: onRefresh ?? null })) {\n flash(t('customers.deals.detail.peopleUpdateError', 'Failed to update linked people.'), 'error')\n }\n } finally {\n setPeopleSaving(false)\n }\n },\n [currentDealId, data?.deal.updatedAt, data?.people, loadPeopleAssociations, onRefresh, peopleEditorIds, runMutationWithContext, setData, t],\n )\n\n const handleCompaniesAssociationsChange = React.useCallback(\n async (nextIds: string[]) => {\n if (!currentDealId) return\n if (sameIdList(nextIds, companiesEditorIds)) return\n const previousIds = companiesEditorIds\n const previousCompanies = data?.companies ?? []\n setCompaniesEditorIds(nextIds)\n setCompaniesSaving(true)\n try {\n await runMutationWithContext(\n () => withScopedApiRequestHeaders(\n buildOptimisticLockHeader(data?.deal.updatedAt),\n () => updateCrud('customers/deals', { id: currentDealId, companyIds: nextIds }),\n ),\n { id: currentDealId, companyIds: nextIds, operation: 'updateDealCompanies' },\n )\n const nextCompanies = await loadCompanyAssociations(nextIds.slice(0, 3))\n setData((prev) =>\n prev\n ? {\n ...prev,\n companies: nextCompanies,\n linkedCompanyIds: nextIds,\n counts: { ...prev.counts, companies: nextIds.length },\n }\n : prev,\n )\n } catch (error) {\n setCompaniesEditorIds(previousIds)\n setData((prev) =>\n prev\n ? {\n ...prev,\n companies: previousCompanies,\n linkedCompanyIds: previousIds,\n counts: { ...prev.counts, companies: previousIds.length },\n }\n : prev,\n )\n // runMutationWithContext already surfaces the conflict bar on a 409; only\n // fall back to the generic flash when this is not a record conflict.\n if (!surfaceRecordConflict(error, t, { onRefresh: onRefresh ?? null })) {\n flash(t('customers.deals.detail.companiesUpdateError', 'Failed to update linked companies.'), 'error')\n }\n } finally {\n setCompaniesSaving(false)\n }\n },\n [\n companiesEditorIds,\n currentDealId,\n data?.companies,\n data?.deal.updatedAt,\n loadCompanyAssociations,\n onRefresh,\n runMutationWithContext,\n setData,\n t,\n ],\n )\n\n return {\n peopleEditorIds,\n companiesEditorIds,\n peopleSaving,\n companiesSaving,\n handlePeopleAssociationsChange,\n handleCompaniesAssociationsChange,\n loadLinkedPeoplePage,\n loadLinkedCompaniesPage,\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,YAAY,WAAW;AACvB,SAAS,aAAa;AACtB,SAAS,6BAA6B;AACtC,SAAS,kBAAkB;AAC3B,SAAS,sBAAsB,mCAAmC;AAClE,SAAS,iCAAiC;AAC1C,SAAS,YAAY;
|
|
4
|
+
"sourcesContent": ["import * as React from 'react'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { updateCrud } from '@open-mercato/ui/backend/utils/crud'\nimport { readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type {\n DealAssociation,\n DealDetailPayload,\n GuardedMutationRunner,\n} from './types'\n\nfunction sameIdList(left: string[], right: string[]): boolean {\n if (left.length !== right.length) return false\n return left.every((value, index) => value === right[index])\n}\n\ntype LinkedPageResult = {\n items: DealAssociation[]\n totalPages: number\n total: number\n}\n\ntype UseDealAssociationsOptions = {\n currentDealId: string | null\n data: DealDetailPayload | null\n setData: React.Dispatch<React.SetStateAction<DealDetailPayload | null>>\n runMutationWithContext: GuardedMutationRunner\n /**\n * Re-fetch the deal detail. Required, not optional: this hook no longer patches the list\n * itself on success, so a caller without it would show a stale list *and* keep a superseded\n * lock token that 409s on the next save. It also backs the conflict bar's refresh on a 409.\n */\n onRefresh: () => void | Promise<void>\n}\n\ntype UseDealAssociationsResult = {\n peopleEditorIds: string[]\n companiesEditorIds: string[]\n peopleSaving: boolean\n companiesSaving: boolean\n handlePeopleAssociationsChange: (nextIds: string[]) => Promise<void>\n handleCompaniesAssociationsChange: (nextIds: string[]) => Promise<void>\n loadLinkedPeoplePage: (page: number, query: string) => Promise<LinkedPageResult>\n loadLinkedCompaniesPage: (page: number, query: string) => Promise<LinkedPageResult>\n}\n\nexport function useDealAssociations({\n currentDealId,\n data,\n setData,\n runMutationWithContext,\n onRefresh,\n}: UseDealAssociationsOptions): UseDealAssociationsResult {\n const t = useT()\n const [peopleEditorIds, setPeopleEditorIds] = React.useState<string[]>([])\n const [companiesEditorIds, setCompaniesEditorIds] = React.useState<string[]>([])\n const [peopleSaving, setPeopleSaving] = React.useState(false)\n const [companiesSaving, setCompaniesSaving] = React.useState(false)\n\n React.useEffect(() => {\n setPeopleEditorIds(data?.linkedPersonIds ?? [])\n setCompaniesEditorIds(data?.linkedCompanyIds ?? [])\n }, [data?.linkedCompanyIds, data?.linkedPersonIds])\n\n const loadLinkedPeoplePage = React.useCallback(\n async (page: number, query: string): Promise<LinkedPageResult> => {\n if (!currentDealId) {\n return { items: [], totalPages: 1, total: 0 }\n }\n const params = new URLSearchParams({\n page: String(page),\n pageSize: '20',\n sort: 'name-asc',\n })\n if (query.trim().length > 0) {\n params.set('search', query.trim())\n }\n const payload = await readApiResultOrThrow<{\n items?: DealAssociation[]\n total?: number\n totalPages?: number\n }>(`/api/customers/deals/${encodeURIComponent(currentDealId)}/people?${params.toString()}`)\n return {\n items: Array.isArray(payload.items) ? payload.items : [],\n totalPages: typeof payload.totalPages === 'number' ? payload.totalPages : 1,\n total: typeof payload.total === 'number' ? payload.total : 0,\n }\n },\n [currentDealId],\n )\n\n const loadLinkedCompaniesPage = React.useCallback(\n async (page: number, query: string): Promise<LinkedPageResult> => {\n if (!currentDealId) {\n return { items: [], totalPages: 1, total: 0 }\n }\n const params = new URLSearchParams({\n page: String(page),\n pageSize: '20',\n sort: 'name-asc',\n })\n if (query.trim().length > 0) {\n params.set('search', query.trim())\n }\n const payload = await readApiResultOrThrow<{\n items?: DealAssociation[]\n total?: number\n totalPages?: number\n }>(`/api/customers/deals/${encodeURIComponent(currentDealId)}/companies?${params.toString()}`)\n return {\n items: Array.isArray(payload.items) ? payload.items : [],\n totalPages: typeof payload.totalPages === 'number' ? payload.totalPages : 1,\n total: typeof payload.total === 'number' ? payload.total : 0,\n }\n },\n [currentDealId],\n )\n\n const handlePeopleAssociationsChange = React.useCallback(\n async (nextIds: string[]) => {\n if (!currentDealId) return\n if (sameIdList(nextIds, peopleEditorIds)) return\n const previousIds = peopleEditorIds\n const previousPeople = data?.people ?? []\n setPeopleEditorIds(nextIds)\n setPeopleSaving(true)\n try {\n await runMutationWithContext(\n () => withScopedApiRequestHeaders(\n buildOptimisticLockHeader(data?.deal.updatedAt),\n () => updateCrud('customers/deals', { id: currentDealId, personIds: nextIds }),\n ),\n { id: currentDealId, personIds: nextIds, operation: 'updateDealPeople' },\n )\n await onRefresh()\n } catch (error) {\n setPeopleEditorIds(previousIds)\n setData((prev) =>\n prev\n ? {\n ...prev,\n people: previousPeople,\n linkedPersonIds: previousIds,\n counts: { ...prev.counts, people: previousIds.length },\n }\n : prev,\n )\n // runMutationWithContext already surfaces the conflict bar on a 409; only\n // fall back to the generic flash when this is not a record conflict.\n if (!surfaceRecordConflict(error, t, { onRefresh })) {\n flash(t('customers.deals.detail.peopleUpdateError', 'Failed to update linked people.'), 'error')\n }\n } finally {\n setPeopleSaving(false)\n }\n },\n [currentDealId, data?.deal.updatedAt, data?.people, onRefresh, peopleEditorIds, runMutationWithContext, setData, t],\n )\n\n const handleCompaniesAssociationsChange = React.useCallback(\n async (nextIds: string[]) => {\n if (!currentDealId) return\n if (sameIdList(nextIds, companiesEditorIds)) return\n const previousIds = companiesEditorIds\n const previousCompanies = data?.companies ?? []\n setCompaniesEditorIds(nextIds)\n setCompaniesSaving(true)\n try {\n await runMutationWithContext(\n () => withScopedApiRequestHeaders(\n buildOptimisticLockHeader(data?.deal.updatedAt),\n () => updateCrud('customers/deals', { id: currentDealId, companyIds: nextIds }),\n ),\n { id: currentDealId, companyIds: nextIds, operation: 'updateDealCompanies' },\n )\n await onRefresh()\n } catch (error) {\n setCompaniesEditorIds(previousIds)\n setData((prev) =>\n prev\n ? {\n ...prev,\n companies: previousCompanies,\n linkedCompanyIds: previousIds,\n counts: { ...prev.counts, companies: previousIds.length },\n }\n : prev,\n )\n // runMutationWithContext already surfaces the conflict bar on a 409; only\n // fall back to the generic flash when this is not a record conflict.\n if (!surfaceRecordConflict(error, t, { onRefresh })) {\n flash(t('customers.deals.detail.companiesUpdateError', 'Failed to update linked companies.'), 'error')\n }\n } finally {\n setCompaniesSaving(false)\n }\n },\n [\n companiesEditorIds,\n currentDealId,\n data?.companies,\n data?.deal.updatedAt,\n onRefresh,\n runMutationWithContext,\n setData,\n t,\n ],\n )\n\n return {\n peopleEditorIds,\n companiesEditorIds,\n peopleSaving,\n companiesSaving,\n handlePeopleAssociationsChange,\n handleCompaniesAssociationsChange,\n loadLinkedPeoplePage,\n loadLinkedCompaniesPage,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -170,9 +170,7 @@ function DealDetailPage({ params }) {
|
|
|
170
170
|
data,
|
|
171
171
|
setData,
|
|
172
172
|
runMutationWithContext,
|
|
173
|
-
onRefresh: () =>
|
|
174
|
-
void loadData();
|
|
175
|
-
}
|
|
173
|
+
onRefresh: () => loadData()
|
|
176
174
|
});
|
|
177
175
|
const { isStageSaving, handleStageChange } = useDealPipeline({
|
|
178
176
|
currentDealId,
|