@open-mercato/core 0.6.1-develop.3219.1.ae5c8a5935 → 0.6.1-develop.3246.1.dbef9d7392
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/auth/cli.js +91 -8
- package/dist/modules/auth/cli.js.map +2 -2
- package/dist/modules/auth/lib/setup-app.js +21 -0
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/customers/api/companies/route.js +11 -1
- package/dist/modules/customers/api/companies/route.js.map +2 -2
- package/dist/modules/customers/api/people/route.js +11 -1
- package/dist/modules/customers/api/people/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies/page.js +78 -35
- package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +74 -30
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people/page.js +78 -35
- package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
- package/dist/modules/customers/commands/people.js +20 -0
- package/dist/modules/customers/commands/people.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/auth/AGENTS.md +23 -0
- package/src/modules/auth/cli.ts +117 -8
- package/src/modules/auth/lib/setup-app.ts +28 -0
- package/src/modules/customers/api/companies/route.ts +10 -0
- package/src/modules/customers/api/people/route.ts +10 -0
- package/src/modules/customers/backend/customers/companies/page.tsx +95 -35
- package/src/modules/customers/backend/customers/deals/page.tsx +91 -30
- package/src/modules/customers/backend/customers/people/page.tsx +96 -35
- package/src/modules/customers/commands/people.ts +30 -0
- package/src/modules/customers/i18n/de.json +8 -0
- package/src/modules/customers/i18n/en.json +8 -0
- package/src/modules/customers/i18n/es.json +8 -0
- package/src/modules/customers/i18n/pl.json +8 -0
package/src/modules/auth/cli.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
5
5
|
import { User, Role, UserRole } from '@open-mercato/core/modules/auth/data/entities'
|
|
6
6
|
import { Tenant, Organization } from '@open-mercato/core/modules/directory/data/entities'
|
|
7
7
|
import { rebuildHierarchyForTenant } from '@open-mercato/core/modules/directory/lib/hierarchy'
|
|
8
|
-
import { ensureRoles, setupInitialTenant, ensureDefaultRoleAcls, ensureCustomRoleAcls } from './lib/setup-app'
|
|
8
|
+
import { ensureRoles, setupInitialTenant, ensureDefaultRoleAcls, ensureCustomRoleAcls, OrgSlugExistsError } from './lib/setup-app'
|
|
9
9
|
import { normalizeTenantId } from './lib/tenantAccess'
|
|
10
10
|
import { computeEmailHash } from './lib/emailHash'
|
|
11
11
|
import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
@@ -403,6 +403,8 @@ const addOrganization: ModuleCli = {
|
|
|
403
403
|
},
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
const SETUP_USAGE = 'Usage: mercato auth setup --orgName <name> --email <email> --password <password> [--orgSlug <slug>] [--roles superadmin,admin,employee] [--skip-password-policy] [--with-examples] [--json]'
|
|
407
|
+
|
|
406
408
|
const setupApp: ModuleCli = {
|
|
407
409
|
command: 'setup',
|
|
408
410
|
async run(rest) {
|
|
@@ -414,6 +416,17 @@ const setupApp: ModuleCli = {
|
|
|
414
416
|
: undefined
|
|
415
417
|
const email = typeof args.email === 'string' ? args.email : undefined
|
|
416
418
|
const password = typeof args.password === 'string' ? args.password : undefined
|
|
419
|
+
const orgSlugRaw = typeof args.orgSlug === 'string'
|
|
420
|
+
? args.orgSlug
|
|
421
|
+
: typeof args.slug === 'string'
|
|
422
|
+
? args.slug
|
|
423
|
+
: undefined
|
|
424
|
+
const orgSlug = orgSlugRaw && orgSlugRaw.trim().length ? orgSlugRaw.trim() : undefined
|
|
425
|
+
if (orgSlug !== undefined && !ORG_SLUG_PATTERN.test(orgSlug)) {
|
|
426
|
+
process.stderr.write(`Invalid --orgSlug: must match ${ORG_SLUG_PATTERN.source} (lowercase, digits, dashes; 1-63 chars; cannot start or end with a dash)\n`)
|
|
427
|
+
process.exitCode = 2
|
|
428
|
+
return
|
|
429
|
+
}
|
|
417
430
|
const rolesCsv = typeof args.roles === 'string'
|
|
418
431
|
? args.roles.trim()
|
|
419
432
|
: 'superadmin,admin,employee'
|
|
@@ -425,29 +438,98 @@ const setupApp: ModuleCli = {
|
|
|
425
438
|
const skipPasswordPolicy = typeof skipPasswordPolicyRaw === 'boolean'
|
|
426
439
|
? skipPasswordPolicyRaw
|
|
427
440
|
: parseBooleanToken(typeof skipPasswordPolicyRaw === 'string' ? skipPasswordPolicyRaw : null) ?? false
|
|
441
|
+
const withExamplesRaw = args['with-examples'] ?? args.withExamples
|
|
442
|
+
const withExamples = typeof withExamplesRaw === 'boolean'
|
|
443
|
+
? withExamplesRaw
|
|
444
|
+
: parseBooleanToken(typeof withExamplesRaw === 'string' ? withExamplesRaw : null) ?? false
|
|
445
|
+
const jsonModeRaw = args.json
|
|
446
|
+
const jsonMode = typeof jsonModeRaw === 'boolean'
|
|
447
|
+
? jsonModeRaw
|
|
448
|
+
: parseBooleanToken(typeof jsonModeRaw === 'string' ? jsonModeRaw : null) ?? false
|
|
428
449
|
if (!orgName || !email || !password) {
|
|
429
|
-
|
|
450
|
+
process.stderr.write(`${SETUP_USAGE}\n`)
|
|
451
|
+
process.exitCode = 2
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
if (!skipPasswordPolicy && !ensurePasswordPolicy(password)) {
|
|
455
|
+
process.exitCode = 2
|
|
430
456
|
return
|
|
431
457
|
}
|
|
432
|
-
if (
|
|
433
|
-
if (skipPasswordPolicy) {
|
|
458
|
+
if (skipPasswordPolicy && !jsonMode) {
|
|
434
459
|
console.warn('⚠️ Password policy validation skipped for setup.')
|
|
435
460
|
}
|
|
436
|
-
|
|
437
|
-
|
|
461
|
+
let restoreConsole: (() => void) | null = null
|
|
462
|
+
if (jsonMode) {
|
|
463
|
+
// Force quiet mode so module bootstrap / lifecycle hooks cannot pollute
|
|
464
|
+
// stdout. The JSON contract relies on a single line, so any console.log
|
|
465
|
+
// from setupInitialTenant or seedExamples would otherwise break the
|
|
466
|
+
// consumer's `jq` pipe. We additionally rebind console.log/info to a
|
|
467
|
+
// no-op for the duration of the call because OM_CLI_QUIET is honored
|
|
468
|
+
// unevenly across module setup hooks. Rebind AFTER early-return
|
|
469
|
+
// validation so the missing-args and password-policy paths can't
|
|
470
|
+
// leak the rebinding into a long-lived process (e.g. test runner).
|
|
471
|
+
process.env.OM_CLI_QUIET = process.env.OM_CLI_QUIET ?? '1'
|
|
472
|
+
const originalLog = console.log
|
|
473
|
+
const originalInfo = console.info
|
|
474
|
+
console.log = () => undefined
|
|
475
|
+
console.info = () => undefined
|
|
476
|
+
restoreConsole = () => {
|
|
477
|
+
console.log = originalLog
|
|
478
|
+
console.info = originalInfo
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const container = await createRequestContainer()
|
|
482
|
+
const em = container.resolve<EntityManager>('em')
|
|
438
483
|
const roleNames = rolesCsv
|
|
439
484
|
? rolesCsv.split(',').map((s) => s.trim()).filter(Boolean)
|
|
440
485
|
: undefined
|
|
441
486
|
|
|
442
487
|
try {
|
|
488
|
+
const modules = getCliModules()
|
|
443
489
|
const result = await setupInitialTenant(em, {
|
|
444
490
|
orgName,
|
|
491
|
+
orgSlug,
|
|
445
492
|
roleNames,
|
|
446
493
|
primaryUser: { email, password, confirm: true },
|
|
447
494
|
includeDerivedUsers: true,
|
|
448
|
-
|
|
495
|
+
// When the caller passes an explicit slug, treat it as a "fresh tenant"
|
|
496
|
+
// signal — silent reuse of an existing user's tenant defeats the point
|
|
497
|
+
// of slugging the new tenant for downstream tooling.
|
|
498
|
+
failIfUserExists: orgSlug !== undefined,
|
|
499
|
+
modules,
|
|
449
500
|
})
|
|
450
501
|
|
|
502
|
+
if (withExamples) {
|
|
503
|
+
const seedCtx = {
|
|
504
|
+
em,
|
|
505
|
+
tenantId: result.tenantId,
|
|
506
|
+
organizationId: result.organizationId,
|
|
507
|
+
container,
|
|
508
|
+
}
|
|
509
|
+
for (const mod of modules) {
|
|
510
|
+
if (mod.setup?.seedExamples) {
|
|
511
|
+
await mod.setup.seedExamples(seedCtx as any)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (jsonMode) {
|
|
517
|
+
const adminSnapshot = pickAdminSnapshot(result.users, email)
|
|
518
|
+
const adminUserId = adminSnapshot && adminSnapshot.user.id != null
|
|
519
|
+
? String(adminSnapshot.user.id)
|
|
520
|
+
: null
|
|
521
|
+
const payload = {
|
|
522
|
+
tenantId: result.tenantId,
|
|
523
|
+
organizationId: result.organizationId,
|
|
524
|
+
adminUserId,
|
|
525
|
+
adminEmail: email,
|
|
526
|
+
reusedExistingUser: result.reusedExistingUser,
|
|
527
|
+
}
|
|
528
|
+
if (restoreConsole) restoreConsole()
|
|
529
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`)
|
|
530
|
+
return
|
|
531
|
+
}
|
|
532
|
+
|
|
451
533
|
if (result.reusedExistingUser) {
|
|
452
534
|
console.log('⚠️ Existing initial user detected during setup.')
|
|
453
535
|
console.log(`⚠️ Email: ${email}`)
|
|
@@ -470,15 +552,42 @@ const setupApp: ModuleCli = {
|
|
|
470
552
|
|
|
471
553
|
if (env.NODE_ENV !== 'test') console.log('✅ Setup complete:', { tenantId: result.tenantId, organizationId: result.organizationId })
|
|
472
554
|
} catch (err) {
|
|
555
|
+
if (restoreConsole) restoreConsole()
|
|
556
|
+
if (err instanceof OrgSlugExistsError) {
|
|
557
|
+
process.stderr.write(`${err.message}\n`)
|
|
558
|
+
process.exitCode = 1
|
|
559
|
+
return
|
|
560
|
+
}
|
|
473
561
|
if (err instanceof Error && err.message === 'USER_EXISTS') {
|
|
474
|
-
|
|
562
|
+
process.stderr.write('Setup aborted: user already exists with the provided email.\n')
|
|
563
|
+
process.exitCode = 1
|
|
475
564
|
return
|
|
476
565
|
}
|
|
477
566
|
throw err
|
|
567
|
+
} finally {
|
|
568
|
+
if (restoreConsole) restoreConsole()
|
|
478
569
|
}
|
|
479
570
|
},
|
|
480
571
|
}
|
|
481
572
|
|
|
573
|
+
const ORG_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
|
|
574
|
+
|
|
575
|
+
function pickAdminSnapshot(
|
|
576
|
+
users: Array<{ user: { id: string; email: string }; roles: string[]; created: boolean }>,
|
|
577
|
+
adminEmail: string,
|
|
578
|
+
): { user: { id: string; email: string } } | undefined {
|
|
579
|
+
const normalizedEmail = adminEmail.toLowerCase()
|
|
580
|
+
const exact = users.find(
|
|
581
|
+
(snapshot) =>
|
|
582
|
+
snapshot.created &&
|
|
583
|
+
snapshot.roles.includes('superadmin') &&
|
|
584
|
+
typeof snapshot.user.email === 'string' &&
|
|
585
|
+
snapshot.user.email.toLowerCase() === normalizedEmail,
|
|
586
|
+
)
|
|
587
|
+
if (exact) return exact
|
|
588
|
+
return users.find((snapshot) => snapshot.roles.includes('superadmin'))
|
|
589
|
+
}
|
|
590
|
+
|
|
482
591
|
const listOrganizations: ModuleCli = {
|
|
483
592
|
command: 'list-orgs',
|
|
484
593
|
async run() {
|
|
@@ -90,6 +90,20 @@ export type SetupInitialTenantOptions = {
|
|
|
90
90
|
includeSuperadminRole?: boolean
|
|
91
91
|
/** Optional list of enabled modules. When provided, module setup hooks are called. */
|
|
92
92
|
modules?: Module[]
|
|
93
|
+
/**
|
|
94
|
+
* Optional global slug to persist on the new Organization. When set, a
|
|
95
|
+
* pre-flight uniqueness check across all tenants throws OrgSlugExistsError
|
|
96
|
+
* on collision so scriptable callers can fail loudly instead of silently
|
|
97
|
+
* reusing or clobbering an existing organization.
|
|
98
|
+
*/
|
|
99
|
+
orgSlug?: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class OrgSlugExistsError extends Error {
|
|
103
|
+
constructor(public readonly slug: string) {
|
|
104
|
+
super(`ORG_SLUG_EXISTS: an organization with slug "${slug}" already exists`)
|
|
105
|
+
this.name = 'OrgSlugExistsError'
|
|
106
|
+
}
|
|
93
107
|
}
|
|
94
108
|
|
|
95
109
|
export type SetupInitialTenantResult = {
|
|
@@ -131,6 +145,19 @@ export async function setupInitialTenant(
|
|
|
131
145
|
throw new Error('USER_EXISTS')
|
|
132
146
|
}
|
|
133
147
|
|
|
148
|
+
if (options.orgSlug) {
|
|
149
|
+
const slugConflict = await findOneWithDecryption(
|
|
150
|
+
em,
|
|
151
|
+
Organization,
|
|
152
|
+
{ slug: options.orgSlug },
|
|
153
|
+
{},
|
|
154
|
+
{ tenantId: null, organizationId: null },
|
|
155
|
+
)
|
|
156
|
+
if (slugConflict) {
|
|
157
|
+
throw new OrgSlugExistsError(options.orgSlug)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
134
161
|
let tenantId: string | undefined
|
|
135
162
|
let organizationId: string | undefined
|
|
136
163
|
let reusedExistingUser = false
|
|
@@ -206,6 +233,7 @@ export async function setupInitialTenant(
|
|
|
206
233
|
|
|
207
234
|
const organization = tem.create(Organization, {
|
|
208
235
|
name: options.orgName,
|
|
236
|
+
slug: options.orgSlug ?? null,
|
|
209
237
|
tenant,
|
|
210
238
|
isActive: true,
|
|
211
239
|
depth: 0,
|
|
@@ -528,5 +528,15 @@ export const openApi = createCustomersCrudOpenApi({
|
|
|
528
528
|
schema: z.object({ id: z.string().uuid() }),
|
|
529
529
|
responseSchema: defaultOkResponseSchema,
|
|
530
530
|
description: 'Deletes a company by id. The identifier can be provided via body or query.',
|
|
531
|
+
errors: [
|
|
532
|
+
{
|
|
533
|
+
status: 422,
|
|
534
|
+
description: 'Company has dependent records (people, deals, or direct staff); unlink or reassign before delete.',
|
|
535
|
+
schema: z.object({
|
|
536
|
+
error: z.string(),
|
|
537
|
+
code: z.literal('COMPANY_HAS_DEPENDENTS'),
|
|
538
|
+
}),
|
|
539
|
+
},
|
|
540
|
+
],
|
|
531
541
|
},
|
|
532
542
|
})
|
|
@@ -559,5 +559,15 @@ export const openApi = createCustomersCrudOpenApi({
|
|
|
559
559
|
schema: z.object({ id: z.string().uuid() }),
|
|
560
560
|
responseSchema: defaultOkResponseSchema,
|
|
561
561
|
description: 'Deletes a person by id. Request body or query may provide the identifier.',
|
|
562
|
+
errors: [
|
|
563
|
+
{
|
|
564
|
+
status: 422,
|
|
565
|
+
description: 'Person has dependent records (e.g. linked deals); unlink or reassign before delete.',
|
|
566
|
+
schema: z.object({
|
|
567
|
+
error: z.string(),
|
|
568
|
+
code: z.literal('PERSON_HAS_DEPENDENTS'),
|
|
569
|
+
}),
|
|
570
|
+
},
|
|
571
|
+
],
|
|
562
572
|
},
|
|
563
573
|
})
|
|
@@ -10,6 +10,9 @@ import { Button } from '@open-mercato/ui/primitives/button'
|
|
|
10
10
|
import { RowActions } from '@open-mercato/ui/backend/RowActions'
|
|
11
11
|
import { apiCall, apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
12
12
|
import { buildCrudExportUrl } from '@open-mercato/ui/backend/utils/crud'
|
|
13
|
+
import { groupBulkDeleteFailures, runBulkDelete } from '@open-mercato/ui/backend/utils/bulkDelete'
|
|
14
|
+
import { coalesceLastOperations } from '@open-mercato/ui/backend/operations/store'
|
|
15
|
+
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
13
16
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
14
17
|
import { E } from '#generated/entities.ids.generated'
|
|
15
18
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
@@ -161,6 +164,26 @@ export default function CustomersCompaniesPage() {
|
|
|
161
164
|
setPageSize(newSize)
|
|
162
165
|
setPage(1)
|
|
163
166
|
}, [])
|
|
167
|
+
|
|
168
|
+
const bulkMutationContextId = 'customers-companies-list:bulk-delete'
|
|
169
|
+
const { runMutation: runBulkMutation, retryLastMutation: retryBulkMutation } = useGuardedMutation<{
|
|
170
|
+
formId: string
|
|
171
|
+
resourceKind: string
|
|
172
|
+
retryLastMutation: () => Promise<boolean>
|
|
173
|
+
}>({
|
|
174
|
+
contextId: bulkMutationContextId,
|
|
175
|
+
blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
|
|
176
|
+
})
|
|
177
|
+
const singleMutationContextId = 'customers-companies-list:single-delete'
|
|
178
|
+
const { runMutation: runSingleMutation, retryLastMutation: retrySingleMutation } = useGuardedMutation<{
|
|
179
|
+
formId: string
|
|
180
|
+
resourceKind: string
|
|
181
|
+
resourceId: string
|
|
182
|
+
retryLastMutation: () => Promise<boolean>
|
|
183
|
+
}>({
|
|
184
|
+
contextId: singleMutationContextId,
|
|
185
|
+
blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
|
|
186
|
+
})
|
|
164
187
|
const fetchDictionaryEntries = React.useCallback(async (kind: DictionaryKindKey) => {
|
|
165
188
|
try {
|
|
166
189
|
const data = await ensureCustomerDictionary(queryClient, kind, scopeVersion)
|
|
@@ -460,14 +483,24 @@ export default function CustomersCompaniesPage() {
|
|
|
460
483
|
})
|
|
461
484
|
if (!confirmed) return
|
|
462
485
|
try {
|
|
463
|
-
await
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
486
|
+
await runSingleMutation({
|
|
487
|
+
operation: async () => {
|
|
488
|
+
await apiCallOrThrow(
|
|
489
|
+
`/api/customers/companies?id=${encodeURIComponent(company.id)}`,
|
|
490
|
+
{
|
|
491
|
+
method: 'DELETE',
|
|
492
|
+
headers: { 'content-type': 'application/json' },
|
|
493
|
+
},
|
|
494
|
+
{ errorMessage: t('customers.companies.list.deleteError') },
|
|
495
|
+
)
|
|
468
496
|
},
|
|
469
|
-
|
|
470
|
-
|
|
497
|
+
context: {
|
|
498
|
+
formId: singleMutationContextId,
|
|
499
|
+
resourceKind: 'customers.company',
|
|
500
|
+
resourceId: company.id,
|
|
501
|
+
retryLastMutation: retrySingleMutation,
|
|
502
|
+
},
|
|
503
|
+
})
|
|
471
504
|
setRows((prev) => prev.filter((row) => row.id !== company.id))
|
|
472
505
|
setTotal((prev) => Math.max(prev - 1, 0))
|
|
473
506
|
handleRefresh()
|
|
@@ -476,7 +509,7 @@ export default function CustomersCompaniesPage() {
|
|
|
476
509
|
const message = err instanceof Error ? err.message : t('customers.companies.list.deleteError')
|
|
477
510
|
flash(message, 'error')
|
|
478
511
|
}
|
|
479
|
-
}, [confirm, handleRefresh, t])
|
|
512
|
+
}, [confirm, handleRefresh, retrySingleMutation, runSingleMutation, singleMutationContextId, t])
|
|
480
513
|
|
|
481
514
|
const handleBulkDelete = React.useCallback(async (selectedRows: CompanyRow[]) => {
|
|
482
515
|
const confirmed = await confirm({
|
|
@@ -485,44 +518,71 @@ export default function CustomersCompaniesPage() {
|
|
|
485
518
|
variant: 'destructive',
|
|
486
519
|
})
|
|
487
520
|
if (!confirmed) return false
|
|
488
|
-
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
521
|
+
|
|
522
|
+
const { succeeded, failures } = await runBulkMutation({
|
|
523
|
+
operation: async () =>
|
|
524
|
+
runBulkDelete(
|
|
525
|
+
selectedRows,
|
|
526
|
+
async (row) => {
|
|
527
|
+
await apiCallOrThrow(`/api/customers/companies?id=${encodeURIComponent(row.id)}`, {
|
|
528
|
+
method: 'DELETE',
|
|
529
|
+
headers: { 'content-type': 'application/json' },
|
|
530
|
+
})
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
fallbackErrorMessage: t('customers.companies.list.deleteError', 'Failed to delete company.'),
|
|
534
|
+
logTag: 'customers.companies.list',
|
|
535
|
+
},
|
|
536
|
+
),
|
|
537
|
+
context: {
|
|
538
|
+
formId: bulkMutationContextId,
|
|
539
|
+
resourceKind: 'customers.company',
|
|
540
|
+
retryLastMutation: retryBulkMutation,
|
|
541
|
+
},
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
if (succeeded.length > 0) {
|
|
545
|
+
const succeededIds = new Set(succeeded.map((r) => r.id))
|
|
546
|
+
setRows((prev) => prev.filter((r) => !succeededIds.has(r.id)))
|
|
547
|
+
setTotal((prev) => Math.max(0, prev - succeeded.length))
|
|
548
|
+
setReloadToken((prev) => prev + 1)
|
|
549
|
+
if (succeeded.length > 1) {
|
|
550
|
+
coalesceLastOperations(succeeded.length, {
|
|
551
|
+
commandId: 'customers.companies.delete',
|
|
552
|
+
actionLabel: t('customers.companies.list.bulkDelete.operationLabel', 'Delete {count} companies', { count: succeeded.length }),
|
|
553
|
+
resourceKind: 'customers.company',
|
|
495
554
|
})
|
|
496
|
-
deletedCount++
|
|
497
|
-
} catch (err) {
|
|
498
|
-
failedIds.push(row.id)
|
|
499
|
-
console.warn('[customers.companies.list] bulk delete failed', row.id, err)
|
|
500
555
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
})
|
|
507
|
-
setTotal((prev) => Math.max(0, prev - deletedCount))
|
|
508
|
-
if (failedIds.length === 0) {
|
|
509
|
-
flash(t('customers.companies.list.bulkDelete.success', '{count} companies deleted', { count: deletedCount }), 'success')
|
|
556
|
+
if (failures.length === 0) {
|
|
557
|
+
flash(
|
|
558
|
+
t('customers.companies.list.bulkDelete.success', '{count} companies deleted', { count: succeeded.length }),
|
|
559
|
+
'success',
|
|
560
|
+
)
|
|
510
561
|
} else {
|
|
511
562
|
flash(
|
|
512
563
|
t('customers.companies.list.bulkDelete.partial', '{deleted} of {total} companies deleted; {failed} failed', {
|
|
513
|
-
deleted:
|
|
564
|
+
deleted: succeeded.length,
|
|
514
565
|
total: selectedRows.length,
|
|
515
|
-
failed:
|
|
566
|
+
failed: failures.length,
|
|
516
567
|
}),
|
|
517
568
|
'warning',
|
|
518
569
|
)
|
|
519
570
|
}
|
|
520
|
-
setReloadToken((prev) => prev + 1)
|
|
521
|
-
} else if (failedIds.length > 0) {
|
|
522
|
-
flash(t('customers.companies.list.bulkDelete.failed', 'Failed to delete {count} companies', { count: failedIds.length }), 'error')
|
|
523
571
|
}
|
|
524
|
-
|
|
525
|
-
|
|
572
|
+
|
|
573
|
+
for (const group of groupBulkDeleteFailures(failures)) {
|
|
574
|
+
const message = group.count === 1
|
|
575
|
+
? group.sampleMessage
|
|
576
|
+
: t(
|
|
577
|
+
'customers.companies.list.bulkDelete.failedGroup',
|
|
578
|
+
'{count} companies could not be deleted: {message}',
|
|
579
|
+
{ count: group.count, message: group.sampleMessage },
|
|
580
|
+
)
|
|
581
|
+
flash(message, 'error')
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return succeeded.length > 0
|
|
585
|
+
}, [bulkMutationContextId, confirm, retryBulkMutation, runBulkMutation, t])
|
|
526
586
|
|
|
527
587
|
const handleFiltersApply = React.useCallback((values: FilterValues) => {
|
|
528
588
|
const next: FilterValues = {}
|
|
@@ -12,6 +12,9 @@ import type { AdvancedFilterState } from '@open-mercato/shared/lib/query/advance
|
|
|
12
12
|
import { deserializeAdvancedFilter, serializeAdvancedFilter } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
13
13
|
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
14
14
|
import { buildCrudExportUrl, deleteCrud } from '@open-mercato/ui/backend/utils/crud'
|
|
15
|
+
import { groupBulkDeleteFailures, runBulkDelete } from '@open-mercato/ui/backend/utils/bulkDelete'
|
|
16
|
+
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
17
|
+
import { coalesceLastOperations } from '@open-mercato/ui/backend/operations/store'
|
|
15
18
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
16
19
|
import { RowActions } from '@open-mercato/ui/backend/RowActions'
|
|
17
20
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
@@ -719,6 +722,26 @@ export default function CustomersDealsPage() {
|
|
|
719
722
|
setReloadToken((token) => token + 1)
|
|
720
723
|
}, [queryClient])
|
|
721
724
|
|
|
725
|
+
const bulkMutationContextId = 'customers-deals-list:bulk-delete'
|
|
726
|
+
const { runMutation: runBulkMutation, retryLastMutation: retryBulkMutation } = useGuardedMutation<{
|
|
727
|
+
formId: string
|
|
728
|
+
resourceKind: string
|
|
729
|
+
retryLastMutation: () => Promise<boolean>
|
|
730
|
+
}>({
|
|
731
|
+
contextId: bulkMutationContextId,
|
|
732
|
+
blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
|
|
733
|
+
})
|
|
734
|
+
const singleMutationContextId = 'customers-deals-list:single-delete'
|
|
735
|
+
const { runMutation: runSingleMutation, retryLastMutation: retrySingleMutation } = useGuardedMutation<{
|
|
736
|
+
formId: string
|
|
737
|
+
resourceKind: string
|
|
738
|
+
resourceId: string
|
|
739
|
+
retryLastMutation: () => Promise<boolean>
|
|
740
|
+
}>({
|
|
741
|
+
contextId: singleMutationContextId,
|
|
742
|
+
blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
|
|
743
|
+
})
|
|
744
|
+
|
|
722
745
|
const handleDeleteDeal = React.useCallback(
|
|
723
746
|
async (dealId: string) => {
|
|
724
747
|
if (pendingDeleteId) return
|
|
@@ -732,9 +755,19 @@ export default function CustomersDealsPage() {
|
|
|
732
755
|
if (!confirmed) return
|
|
733
756
|
setPendingDeleteId(dealId)
|
|
734
757
|
try {
|
|
735
|
-
await
|
|
736
|
-
|
|
737
|
-
|
|
758
|
+
await runSingleMutation({
|
|
759
|
+
operation: async () => {
|
|
760
|
+
await deleteCrud('customers/deals', {
|
|
761
|
+
body: { id: dealId },
|
|
762
|
+
errorMessage: t('customers.deals.list.deleteError', 'Failed to delete deal.'),
|
|
763
|
+
})
|
|
764
|
+
},
|
|
765
|
+
context: {
|
|
766
|
+
formId: singleMutationContextId,
|
|
767
|
+
resourceKind: 'customers.deal',
|
|
768
|
+
resourceId: dealId,
|
|
769
|
+
retryLastMutation: retrySingleMutation,
|
|
770
|
+
},
|
|
738
771
|
})
|
|
739
772
|
flash(t('customers.deals.list.deleteSuccess', 'Deal deleted.'), 'success')
|
|
740
773
|
setRows((prev) => prev.filter((row) => row.id !== dealId))
|
|
@@ -750,7 +783,7 @@ export default function CustomersDealsPage() {
|
|
|
750
783
|
setPendingDeleteId(null)
|
|
751
784
|
}
|
|
752
785
|
},
|
|
753
|
-
[confirm, handleRefresh, pendingDeleteId, t],
|
|
786
|
+
[confirm, handleRefresh, pendingDeleteId, retrySingleMutation, runSingleMutation, singleMutationContextId, t],
|
|
754
787
|
)
|
|
755
788
|
|
|
756
789
|
const handlePageSizeChange = React.useCallback((newSize: number) => {
|
|
@@ -765,43 +798,71 @@ export default function CustomersDealsPage() {
|
|
|
765
798
|
variant: 'destructive',
|
|
766
799
|
})
|
|
767
800
|
if (!confirmed) return false
|
|
768
|
-
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
801
|
+
|
|
802
|
+
const { succeeded, failures } = await runBulkMutation({
|
|
803
|
+
operation: async () =>
|
|
804
|
+
runBulkDelete(
|
|
805
|
+
selectedRows,
|
|
806
|
+
async (row) => {
|
|
807
|
+
await deleteCrud('customers/deals', {
|
|
808
|
+
body: { id: row.id },
|
|
809
|
+
errorMessage: t('customers.deals.list.deleteError', 'Failed to delete deal.'),
|
|
810
|
+
})
|
|
811
|
+
},
|
|
812
|
+
{
|
|
813
|
+
fallbackErrorMessage: t('customers.deals.list.deleteError', 'Failed to delete deal.'),
|
|
814
|
+
logTag: 'customers.deals.list',
|
|
815
|
+
},
|
|
816
|
+
),
|
|
817
|
+
context: {
|
|
818
|
+
formId: bulkMutationContextId,
|
|
819
|
+
resourceKind: 'customers.deal',
|
|
820
|
+
retryLastMutation: retryBulkMutation,
|
|
821
|
+
},
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
if (succeeded.length > 0) {
|
|
825
|
+
const succeededIds = new Set(succeeded.map((r) => r.id))
|
|
826
|
+
setRows((prev) => prev.filter((r) => !succeededIds.has(r.id)))
|
|
827
|
+
setTotal((prev) => Math.max(0, prev - succeeded.length))
|
|
828
|
+
setReloadToken((prev) => prev + 1)
|
|
829
|
+
if (succeeded.length > 1) {
|
|
830
|
+
coalesceLastOperations(succeeded.length, {
|
|
831
|
+
commandId: 'customers.deals.delete',
|
|
832
|
+
actionLabel: t('customers.deals.list.bulkDelete.operationLabel', 'Delete {count} deals', { count: succeeded.length }),
|
|
833
|
+
resourceKind: 'customers.deal',
|
|
775
834
|
})
|
|
776
|
-
deletedCount++
|
|
777
|
-
} catch (err) {
|
|
778
|
-
failedIds.push(row.id)
|
|
779
|
-
console.warn('[customers.deals.list] bulk delete failed', row.id, err)
|
|
780
835
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
})
|
|
787
|
-
setTotal((prev) => Math.max(0, prev - deletedCount))
|
|
788
|
-
if (failedIds.length === 0) {
|
|
789
|
-
flash(t('customers.deals.list.bulkDelete.success', '{count} deals deleted', { count: deletedCount }), 'success')
|
|
836
|
+
if (failures.length === 0) {
|
|
837
|
+
flash(
|
|
838
|
+
t('customers.deals.list.bulkDelete.success', '{count} deals deleted', { count: succeeded.length }),
|
|
839
|
+
'success',
|
|
840
|
+
)
|
|
790
841
|
} else {
|
|
791
842
|
flash(
|
|
792
843
|
t('customers.deals.list.bulkDelete.partial', '{deleted} of {total} deals deleted; {failed} failed', {
|
|
793
|
-
deleted:
|
|
844
|
+
deleted: succeeded.length,
|
|
794
845
|
total: selectedRows.length,
|
|
795
|
-
failed:
|
|
846
|
+
failed: failures.length,
|
|
796
847
|
}),
|
|
797
848
|
'warning',
|
|
798
849
|
)
|
|
799
850
|
}
|
|
800
|
-
} else if (failedIds.length > 0) {
|
|
801
|
-
flash(t('customers.deals.list.bulkDelete.failed', 'Failed to delete {count} deals', { count: failedIds.length }), 'error')
|
|
802
851
|
}
|
|
803
|
-
|
|
804
|
-
|
|
852
|
+
|
|
853
|
+
for (const group of groupBulkDeleteFailures(failures)) {
|
|
854
|
+
const message = group.count === 1
|
|
855
|
+
? group.sampleMessage
|
|
856
|
+
: t(
|
|
857
|
+
'customers.deals.list.bulkDelete.failedGroup',
|
|
858
|
+
'{count} deals could not be deleted: {message}',
|
|
859
|
+
{ count: group.count, message: group.sampleMessage },
|
|
860
|
+
)
|
|
861
|
+
flash(message, 'error')
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
return succeeded.length > 0
|
|
865
|
+
}, [bulkMutationContextId, confirm, retryBulkMutation, runBulkMutation, t])
|
|
805
866
|
|
|
806
867
|
const personOptions = peopleState.options
|
|
807
868
|
const companyOptions = companiesState.options
|