@open-mercato/core 0.6.6-develop.6184.1.b7e55f8d61 → 0.6.6-develop.6198.1.0822f97cc6
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/frontend/login.js +5 -5
- package/dist/modules/auth/frontend/login.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js +3 -2
- package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js.map +2 -2
- package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js +26 -14
- package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js.map +2 -2
- package/dist/modules/customers/api/deals/[id]/route.js +169 -149
- package/dist/modules/customers/api/deals/[id]/route.js.map +3 -3
- package/dist/modules/customers/components/detail/InlineEditors.js +9 -4
- package/dist/modules/customers/components/detail/InlineEditors.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/[id]/page.js +13 -9
- package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/detail-page-refresh.js +17 -0
- package/dist/modules/integrations/backend/integrations/detail-page-refresh.js.map +7 -0
- package/dist/modules/sales/components/documents/SalesDocumentForm.js +299 -297
- package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
- package/dist/modules/workflows/components/EdgeEditDialog.js +120 -141
- package/dist/modules/workflows/components/EdgeEditDialog.js.map +2 -2
- package/dist/modules/workflows/components/NodeEditDialog.js +114 -118
- package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
- package/dist/modules/workflows/frontend/checkout-demo/page.js +155 -260
- package/dist/modules/workflows/frontend/checkout-demo/page.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/frontend/login.tsx +5 -5
- package/src/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.tsx +4 -3
- package/src/modules/catalog/widgets/injection/product-seo/widget.client.tsx +27 -17
- package/src/modules/customers/api/deals/[id]/route.ts +208 -174
- package/src/modules/customers/components/detail/InlineEditors.tsx +6 -10
- package/src/modules/integrations/backend/integrations/[id]/page.tsx +13 -9
- package/src/modules/integrations/backend/integrations/detail-page-refresh.ts +36 -0
- package/src/modules/sales/components/documents/SalesDocumentForm.tsx +351 -327
- package/src/modules/workflows/components/EdgeEditDialog.tsx +132 -157
- package/src/modules/workflows/components/NodeEditDialog.tsx +116 -121
- package/src/modules/workflows/frontend/checkout-demo/page.tsx +172 -239
- package/src/modules/workflows/i18n/de.json +2 -0
- package/src/modules/workflows/i18n/en.json +2 -0
- package/src/modules/workflows/i18n/es.json +2 -0
- package/src/modules/workflows/i18n/pl.json +2 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import { CrudForm, type CrudCustomFieldRenderProps, type CrudField, type CrudFormGroup } from '@open-mercato/ui/backend/CrudForm'
|
|
4
|
+
import { CrudForm, type CrudCustomFieldRenderProps, type CrudField, type CrudFormGroup, type CrudFormGroupComponentProps } from '@open-mercato/ui/backend/CrudForm'
|
|
5
5
|
import { LookupSelect, type LookupSelectItem } from '@open-mercato/ui/backend/inputs'
|
|
6
6
|
import { Input } from '@open-mercato/ui/primitives/input'
|
|
7
7
|
import { EmailInput } from '@open-mercato/ui/primitives/email-input'
|
|
@@ -406,6 +406,353 @@ function normalizeAddressDraft(draft?: AddressDraft | null): Record<string, unkn
|
|
|
406
406
|
return Object.keys(normalized).length ? normalized : null
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
type DocumentNumberFieldProps = CrudCustomFieldRenderProps & { t: Translator }
|
|
410
|
+
|
|
411
|
+
type BillingAddressSectionFieldProps = CrudCustomFieldRenderProps & {
|
|
412
|
+
t: Translator
|
|
413
|
+
addressesLoading: boolean
|
|
414
|
+
addressOptions: AddressOption[]
|
|
415
|
+
addressFormat: AddressFormatStrategy
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
type CustomerGroupComponentProps = CrudFormGroupComponentProps & {
|
|
419
|
+
t: Translator
|
|
420
|
+
customers: CustomerOption[]
|
|
421
|
+
setCustomers: React.Dispatch<React.SetStateAction<CustomerOption[]>>
|
|
422
|
+
customerQuerySetter: React.MutableRefObject<((value: string) => void) | null>
|
|
423
|
+
loadAddresses: (customerId?: string | null) => Promise<AddressOption[]>
|
|
424
|
+
loadCustomers: (query?: string) => Promise<CustomerOption[]>
|
|
425
|
+
fetchCustomerEmail: (id: string, kindHint?: 'person' | 'company') => Promise<string | null>
|
|
426
|
+
resetAddressFormState: (updateValue: (key: string, value: unknown) => void) => void
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function DocumentNumberField({ value, setValue, values, t }: DocumentNumberFieldProps) {
|
|
430
|
+
const formValues = (values ?? {}) as Partial<SalesDocumentFormValues>
|
|
431
|
+
const kind: DocumentKind = formValues.documentKind === 'order' ? 'order' : 'quote'
|
|
432
|
+
const [loading, setLoading] = React.useState(false)
|
|
433
|
+
const [error, setError] = React.useState<string | null>(null)
|
|
434
|
+
const autoValueRef = React.useRef<string | null>(null)
|
|
435
|
+
const lastKindRef = React.useRef<DocumentKind | null>(null)
|
|
436
|
+
|
|
437
|
+
const requestNumber = React.useCallback(async () => {
|
|
438
|
+
setLoading(true)
|
|
439
|
+
setError(null)
|
|
440
|
+
try {
|
|
441
|
+
const call = await apiCall<{ number?: string; error?: string }>('/api/sales/document-numbers', {
|
|
442
|
+
method: 'POST',
|
|
443
|
+
headers: { 'content-type': 'application/json' },
|
|
444
|
+
body: JSON.stringify({ kind }),
|
|
445
|
+
})
|
|
446
|
+
const nextNumber = typeof call.result?.number === 'string' ? call.result.number : null
|
|
447
|
+
if (call.ok && nextNumber) {
|
|
448
|
+
autoValueRef.current = nextNumber
|
|
449
|
+
lastKindRef.current = kind
|
|
450
|
+
setValue(nextNumber)
|
|
451
|
+
} else {
|
|
452
|
+
setError(call.result?.error || t('sales.documents.form.errors.numberGenerate', 'Could not generate a document number.'))
|
|
453
|
+
}
|
|
454
|
+
} catch (err) {
|
|
455
|
+
console.error('sales.documents.generateNumber', err)
|
|
456
|
+
setError(t('sales.documents.form.errors.numberGenerate', 'Could not generate a document number.'))
|
|
457
|
+
} finally {
|
|
458
|
+
setLoading(false)
|
|
459
|
+
}
|
|
460
|
+
}, [kind, setValue, t])
|
|
461
|
+
|
|
462
|
+
React.useEffect(() => {
|
|
463
|
+
const current = typeof value === 'string' ? value.trim() : ''
|
|
464
|
+
if (!current.length && !lastKindRef.current) {
|
|
465
|
+
void requestNumber()
|
|
466
|
+
} else {
|
|
467
|
+
lastKindRef.current = kind
|
|
468
|
+
}
|
|
469
|
+
}, [kind, requestNumber, value])
|
|
470
|
+
|
|
471
|
+
return (
|
|
472
|
+
<div className="space-y-2">
|
|
473
|
+
<div className="flex w-full flex-col gap-2 md:flex-row md:items-center md:gap-3">
|
|
474
|
+
<Input
|
|
475
|
+
value={typeof value === 'string' ? value : ''}
|
|
476
|
+
onChange={(event) => setValue(event.target.value)}
|
|
477
|
+
disabled={loading}
|
|
478
|
+
spellCheck={false}
|
|
479
|
+
className="w-full md:flex-1"
|
|
480
|
+
/>
|
|
481
|
+
<Button type="button" variant="outline" onClick={requestNumber} disabled={loading}>
|
|
482
|
+
{loading
|
|
483
|
+
? t('sales.documents.form.numberLoading', 'Generating…')
|
|
484
|
+
: t('sales.documents.form.numberRefresh', 'Generate')}
|
|
485
|
+
</Button>
|
|
486
|
+
</div>
|
|
487
|
+
<p className="text-xs text-muted-foreground">
|
|
488
|
+
{kind === 'order'
|
|
489
|
+
? t('sales.documents.form.numberHintOrder', 'Format applies to orders and uses the configured counter.')
|
|
490
|
+
: t('sales.documents.form.numberHintQuote', 'Format applies to quotes and uses the configured counter.')}
|
|
491
|
+
</p>
|
|
492
|
+
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
|
493
|
+
</div>
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function BillingAddressSectionField({ values, setFormValue, t, addressesLoading, addressOptions, addressFormat }: BillingAddressSectionFieldProps) {
|
|
498
|
+
const formValues = (values ?? {}) as Partial<SalesDocumentFormValues>
|
|
499
|
+
const updateValue = setFormValue ?? (() => {})
|
|
500
|
+
const useCustom = formValues.useCustomBilling === true
|
|
501
|
+
const selectedId = typeof formValues.billingAddressId === 'string' ? formValues.billingAddressId : ''
|
|
502
|
+
const draft = (formValues.billingAddressDraft ?? {}) as AddressDraft
|
|
503
|
+
const customerRequired = !formValues.customerEntityId
|
|
504
|
+
const sameAsShipping = formValues.sameAsShipping !== false
|
|
505
|
+
const shippingId = typeof formValues.shippingAddressId === 'string' ? formValues.shippingAddressId : null
|
|
506
|
+
const shippingDraft = (formValues.shippingAddressDraft ?? {}) as AddressDraft
|
|
507
|
+
const shippingDraftKey = JSON.stringify(shippingDraft)
|
|
508
|
+
const billingDraftKey = JSON.stringify(draft)
|
|
509
|
+
const useCustomShipping = formValues.useCustomShipping === true
|
|
510
|
+
|
|
511
|
+
React.useEffect(() => {
|
|
512
|
+
if (!sameAsShipping) return
|
|
513
|
+
if ((formValues.billingAddressId ?? null) !== shippingId) {
|
|
514
|
+
updateValue('billingAddressId', shippingId)
|
|
515
|
+
}
|
|
516
|
+
if (useCustomShipping !== (formValues.useCustomBilling === true)) {
|
|
517
|
+
updateValue('useCustomBilling', useCustomShipping)
|
|
518
|
+
}
|
|
519
|
+
if (useCustomShipping && shippingDraftKey !== billingDraftKey) {
|
|
520
|
+
updateValue('billingAddressDraft', shippingDraft)
|
|
521
|
+
}
|
|
522
|
+
}, [
|
|
523
|
+
billingDraftKey,
|
|
524
|
+
sameAsShipping,
|
|
525
|
+
updateValue,
|
|
526
|
+
shippingDraft,
|
|
527
|
+
shippingDraftKey,
|
|
528
|
+
shippingId,
|
|
529
|
+
useCustomShipping,
|
|
530
|
+
formValues.billingAddressId,
|
|
531
|
+
formValues.useCustomBilling,
|
|
532
|
+
])
|
|
533
|
+
|
|
534
|
+
return (
|
|
535
|
+
<div className="space-y-3">
|
|
536
|
+
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
|
537
|
+
<div>
|
|
538
|
+
<p className="text-base font-semibold">
|
|
539
|
+
{t('sales.documents.form.billing.title', 'Billing address')}
|
|
540
|
+
</p>
|
|
541
|
+
<p className="text-xs text-muted-foreground">
|
|
542
|
+
{sameAsShipping
|
|
543
|
+
? t('sales.documents.form.address.sameAsShippingHint', 'Billing will mirror the shipping address. Uncheck to edit.')
|
|
544
|
+
: t('sales.documents.form.billing.hint', 'Select an address or define a new one.')}
|
|
545
|
+
</p>
|
|
546
|
+
</div>
|
|
547
|
+
<SwitchField
|
|
548
|
+
label={t('sales.documents.form.address.sameAsShipping', 'Same as shipping')}
|
|
549
|
+
flip
|
|
550
|
+
checked={sameAsShipping}
|
|
551
|
+
onCheckedChange={(checked) => {
|
|
552
|
+
updateValue('sameAsShipping', checked)
|
|
553
|
+
if (checked) {
|
|
554
|
+
updateValue('useCustomBilling', useCustomShipping)
|
|
555
|
+
updateValue('billingAddressId', shippingId)
|
|
556
|
+
updateValue('billingAddressDraft', shippingDraft)
|
|
557
|
+
}
|
|
558
|
+
}}
|
|
559
|
+
/>
|
|
560
|
+
</div>
|
|
561
|
+
|
|
562
|
+
{!sameAsShipping ? (
|
|
563
|
+
<>
|
|
564
|
+
{!useCustom ? (
|
|
565
|
+
<Select
|
|
566
|
+
value={selectedId || undefined}
|
|
567
|
+
onValueChange={(value) => updateValue('billingAddressId', value || null)}
|
|
568
|
+
disabled={addressesLoading || customerRequired}
|
|
569
|
+
>
|
|
570
|
+
<SelectTrigger>
|
|
571
|
+
<SelectValue
|
|
572
|
+
placeholder={
|
|
573
|
+
addressesLoading
|
|
574
|
+
? t('sales.documents.form.address.loading', 'Loading addresses…')
|
|
575
|
+
: t('sales.documents.form.address.placeholder', 'Select address')
|
|
576
|
+
}
|
|
577
|
+
/>
|
|
578
|
+
</SelectTrigger>
|
|
579
|
+
<SelectContent>
|
|
580
|
+
{addressOptions.map((addr) => {
|
|
581
|
+
const optionLabel = addr.summary ? `${addr.label} — ${addr.summary}` : addr.label
|
|
582
|
+
return (
|
|
583
|
+
<SelectItem key={addr.id} value={addr.id}>{optionLabel}</SelectItem>
|
|
584
|
+
)
|
|
585
|
+
})}
|
|
586
|
+
</SelectContent>
|
|
587
|
+
</Select>
|
|
588
|
+
) : null}
|
|
589
|
+
|
|
590
|
+
<SwitchField
|
|
591
|
+
label={t('sales.documents.form.shipping.custom', 'Define new address')}
|
|
592
|
+
flip
|
|
593
|
+
checked={useCustom}
|
|
594
|
+
onCheckedChange={(checked) => updateValue('useCustomBilling', checked)}
|
|
595
|
+
disabled={false}
|
|
596
|
+
/>
|
|
597
|
+
|
|
598
|
+
{useCustom ? (
|
|
599
|
+
<div className="space-y-3">
|
|
600
|
+
<AddressEditor
|
|
601
|
+
value={draft}
|
|
602
|
+
format={addressFormat}
|
|
603
|
+
t={t}
|
|
604
|
+
onChange={(next) => updateValue('billingAddressDraft', next)}
|
|
605
|
+
hidePrimaryToggle
|
|
606
|
+
/>
|
|
607
|
+
<SwitchField
|
|
608
|
+
containerClassName="col-span-2"
|
|
609
|
+
label={t('sales.documents.form.address.saveToCustomer', 'Save this address to the customer')}
|
|
610
|
+
flip
|
|
611
|
+
checked={formValues.saveBillingAddress === true}
|
|
612
|
+
onCheckedChange={(checked) => updateValue('saveBillingAddress', checked)}
|
|
613
|
+
/>
|
|
614
|
+
</div>
|
|
615
|
+
) : null}
|
|
616
|
+
</>
|
|
617
|
+
) : null}
|
|
618
|
+
</div>
|
|
619
|
+
)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function CustomerGroupComponent({ values, setValue, t, customers, setCustomers, customerQuerySetter, loadAddresses, loadCustomers, fetchCustomerEmail, resetAddressFormState }: CustomerGroupComponentProps) {
|
|
623
|
+
const emailValue = typeof values.customerEmail === 'string' ? values.customerEmail : ''
|
|
624
|
+
const { duplicate, checking } = useEmailDuplicateCheck(emailValue, {
|
|
625
|
+
disabled: false,
|
|
626
|
+
debounceMs: 400,
|
|
627
|
+
matchMode: 'prefix',
|
|
628
|
+
})
|
|
629
|
+
return (
|
|
630
|
+
<div className="space-y-4">
|
|
631
|
+
<div className="space-y-3">
|
|
632
|
+
<LookupSelect
|
|
633
|
+
value={typeof values.customerEntityId === 'string' ? values.customerEntityId : null}
|
|
634
|
+
onChange={(next) => {
|
|
635
|
+
if (next !== values.customerEntityId) {
|
|
636
|
+
resetAddressFormState(setValue)
|
|
637
|
+
}
|
|
638
|
+
setValue('customerEntityId', next)
|
|
639
|
+
loadAddresses(next).then((addrs) => {
|
|
640
|
+
const primary = addrs.find((addr) => addr.isPrimary)
|
|
641
|
+
if (primary) {
|
|
642
|
+
setValue('shippingAddressId', primary.id)
|
|
643
|
+
}
|
|
644
|
+
}).catch((err) => { console.error('sales.documents.autoSelectAddress', err) })
|
|
645
|
+
if (next) {
|
|
646
|
+
const match = customers.find((entry) => entry.id === next)
|
|
647
|
+
const possibleEmail =
|
|
648
|
+
typeof match?.primaryEmail === 'string' && match.primaryEmail.length
|
|
649
|
+
? match.primaryEmail
|
|
650
|
+
: null
|
|
651
|
+
if (possibleEmail) {
|
|
652
|
+
setValue('customerEmail', possibleEmail)
|
|
653
|
+
} else {
|
|
654
|
+
fetchCustomerEmail(next, match?.kind)
|
|
655
|
+
.then((email) => {
|
|
656
|
+
if (email) setValue('customerEmail', email)
|
|
657
|
+
})
|
|
658
|
+
.catch(() => {})
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}}
|
|
662
|
+
fetchItems={async (query) => {
|
|
663
|
+
const options = await loadCustomers(query)
|
|
664
|
+
return options.map<LookupSelectItem>((opt) => ({
|
|
665
|
+
id: opt.id,
|
|
666
|
+
title: opt.label,
|
|
667
|
+
subtitle: opt.subtitle ?? undefined,
|
|
668
|
+
icon:
|
|
669
|
+
opt.kind === 'person' ? (
|
|
670
|
+
<UserRound className="h-5 w-5 text-muted-foreground" />
|
|
671
|
+
) : (
|
|
672
|
+
<Building2 className="h-5 w-5 text-muted-foreground" />
|
|
673
|
+
),
|
|
674
|
+
}))
|
|
675
|
+
}}
|
|
676
|
+
actionSlot={
|
|
677
|
+
<CustomerQuickCreate
|
|
678
|
+
t={t}
|
|
679
|
+
onCreated={({ id, email, label, kind, subtitle }) => {
|
|
680
|
+
// Seed the search box with the new customer's display name so it renders immediately
|
|
681
|
+
customerQuerySetter.current?.(label)
|
|
682
|
+
setCustomers((prev) => {
|
|
683
|
+
const exists = prev.some((entry) => entry.id === id)
|
|
684
|
+
if (exists) return prev
|
|
685
|
+
const next: CustomerOption = {
|
|
686
|
+
id,
|
|
687
|
+
label,
|
|
688
|
+
subtitle: subtitle ?? undefined,
|
|
689
|
+
kind,
|
|
690
|
+
primaryEmail: email ?? null,
|
|
691
|
+
}
|
|
692
|
+
return [next, ...prev]
|
|
693
|
+
})
|
|
694
|
+
setValue('customerEntityId', id)
|
|
695
|
+
resetAddressFormState(setValue)
|
|
696
|
+
loadAddresses(id).then((addrs) => {
|
|
697
|
+
const primary = addrs.find((addr) => addr.isPrimary)
|
|
698
|
+
if (primary) {
|
|
699
|
+
setValue('shippingAddressId', primary.id)
|
|
700
|
+
}
|
|
701
|
+
}).catch((err) => { console.error('sales.documents.autoSelectAddress', err) })
|
|
702
|
+
if (email && !values.customerEmail) {
|
|
703
|
+
setValue('customerEmail', email)
|
|
704
|
+
}
|
|
705
|
+
}}
|
|
706
|
+
/>
|
|
707
|
+
}
|
|
708
|
+
onReady={({ setQuery }) => {
|
|
709
|
+
customerQuerySetter.current = setQuery
|
|
710
|
+
}}
|
|
711
|
+
searchPlaceholder={t('sales.documents.form.customer.placeholder', 'Search customers…')}
|
|
712
|
+
loadingLabel={t('sales.documents.form.customer.loading', 'Loading customers…')}
|
|
713
|
+
emptyLabel={t('sales.documents.form.customer.empty', 'No customers found.')}
|
|
714
|
+
selectedHintLabel={(id) => t('sales.documents.form.customer.selected', 'Selected customer: {{id}}', { id })}
|
|
715
|
+
/>
|
|
716
|
+
</div>
|
|
717
|
+
<div className="space-y-2">
|
|
718
|
+
<EmailInput
|
|
719
|
+
value={emailValue}
|
|
720
|
+
onChange={(event) => setValue('customerEmail', event.target.value)}
|
|
721
|
+
placeholder={t('sales.documents.form.email.placeholder', 'Email used for the document')}
|
|
722
|
+
spellCheck={false}
|
|
723
|
+
/>
|
|
724
|
+
{duplicate ? (
|
|
725
|
+
<div className="flex items-center justify-between rounded border bg-muted px-3 py-2 text-xs text-muted-foreground">
|
|
726
|
+
<span>
|
|
727
|
+
{t('customers.people.form.emailDuplicateNotice', undefined, { name: duplicate.displayName })}
|
|
728
|
+
</span>
|
|
729
|
+
<Button
|
|
730
|
+
size="sm"
|
|
731
|
+
variant="secondary"
|
|
732
|
+
className="px-4"
|
|
733
|
+
type="button"
|
|
734
|
+
disabled={values.customerEntityId === duplicate.id}
|
|
735
|
+
aria-disabled={values.customerEntityId === duplicate.id}
|
|
736
|
+
onClick={() => {
|
|
737
|
+
setValue('customerEntityId', duplicate.id)
|
|
738
|
+
resetAddressFormState(setValue)
|
|
739
|
+
loadAddresses(duplicate.id)
|
|
740
|
+
}}
|
|
741
|
+
>
|
|
742
|
+
{values.customerEntityId === duplicate.id
|
|
743
|
+
? t('sales.documents.form.email.alreadySelected', 'Selected customer')
|
|
744
|
+
: t('sales.documents.form.email.selectCustomer', 'Select customer')}
|
|
745
|
+
</Button>
|
|
746
|
+
</div>
|
|
747
|
+
) : null}
|
|
748
|
+
{!duplicate && checking ? (
|
|
749
|
+
<p className="text-xs text-muted-foreground">{t('customers.people.form.emailChecking')}</p>
|
|
750
|
+
) : null}
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
)
|
|
754
|
+
}
|
|
755
|
+
|
|
409
756
|
export function SalesDocumentForm({ onCreated, isSubmitting = false, initialKind, inboxPreFill }: SalesDocumentFormProps) {
|
|
410
757
|
const t = useT()
|
|
411
758
|
const [customers, setCustomers] = React.useState<CustomerOption[]>([])
|
|
@@ -717,74 +1064,6 @@ export function SalesDocumentForm({ onCreated, isSubmitting = false, initialKind
|
|
|
717
1064
|
[],
|
|
718
1065
|
)
|
|
719
1066
|
|
|
720
|
-
function DocumentNumberField({ value, setValue, values }: CrudCustomFieldRenderProps) {
|
|
721
|
-
const formValues = (values ?? {}) as Partial<SalesDocumentFormValues>
|
|
722
|
-
const kind: DocumentKind = formValues.documentKind === 'order' ? 'order' : 'quote'
|
|
723
|
-
const [loading, setLoading] = React.useState(false)
|
|
724
|
-
const [error, setError] = React.useState<string | null>(null)
|
|
725
|
-
const autoValueRef = React.useRef<string | null>(null)
|
|
726
|
-
const lastKindRef = React.useRef<DocumentKind | null>(null)
|
|
727
|
-
|
|
728
|
-
const requestNumber = React.useCallback(async () => {
|
|
729
|
-
setLoading(true)
|
|
730
|
-
setError(null)
|
|
731
|
-
try {
|
|
732
|
-
const call = await apiCall<{ number?: string; error?: string }>('/api/sales/document-numbers', {
|
|
733
|
-
method: 'POST',
|
|
734
|
-
headers: { 'content-type': 'application/json' },
|
|
735
|
-
body: JSON.stringify({ kind }),
|
|
736
|
-
})
|
|
737
|
-
const nextNumber = typeof call.result?.number === 'string' ? call.result.number : null
|
|
738
|
-
if (call.ok && nextNumber) {
|
|
739
|
-
autoValueRef.current = nextNumber
|
|
740
|
-
lastKindRef.current = kind
|
|
741
|
-
setValue(nextNumber)
|
|
742
|
-
} else {
|
|
743
|
-
setError(call.result?.error || t('sales.documents.form.errors.numberGenerate', 'Could not generate a document number.'))
|
|
744
|
-
}
|
|
745
|
-
} catch (err) {
|
|
746
|
-
console.error('sales.documents.generateNumber', err)
|
|
747
|
-
setError(t('sales.documents.form.errors.numberGenerate', 'Could not generate a document number.'))
|
|
748
|
-
} finally {
|
|
749
|
-
setLoading(false)
|
|
750
|
-
}
|
|
751
|
-
}, [kind, setValue, t])
|
|
752
|
-
|
|
753
|
-
React.useEffect(() => {
|
|
754
|
-
const current = typeof value === 'string' ? value.trim() : ''
|
|
755
|
-
if (!current.length && !lastKindRef.current) {
|
|
756
|
-
void requestNumber()
|
|
757
|
-
} else {
|
|
758
|
-
lastKindRef.current = kind
|
|
759
|
-
}
|
|
760
|
-
}, [kind, requestNumber, value])
|
|
761
|
-
|
|
762
|
-
return (
|
|
763
|
-
<div className="space-y-2">
|
|
764
|
-
<div className="flex w-full flex-col gap-2 md:flex-row md:items-center md:gap-3">
|
|
765
|
-
<Input
|
|
766
|
-
value={typeof value === 'string' ? value : ''}
|
|
767
|
-
onChange={(event) => setValue(event.target.value)}
|
|
768
|
-
disabled={loading}
|
|
769
|
-
spellCheck={false}
|
|
770
|
-
className="w-full md:flex-1"
|
|
771
|
-
/>
|
|
772
|
-
<Button type="button" variant="outline" onClick={requestNumber} disabled={loading}>
|
|
773
|
-
{loading
|
|
774
|
-
? t('sales.documents.form.numberLoading', 'Generating…')
|
|
775
|
-
: t('sales.documents.form.numberRefresh', 'Generate')}
|
|
776
|
-
</Button>
|
|
777
|
-
</div>
|
|
778
|
-
<p className="text-xs text-muted-foreground">
|
|
779
|
-
{kind === 'order'
|
|
780
|
-
? t('sales.documents.form.numberHintOrder', 'Format applies to orders and uses the configured counter.')
|
|
781
|
-
: t('sales.documents.form.numberHintQuote', 'Format applies to quotes and uses the configured counter.')}
|
|
782
|
-
</p>
|
|
783
|
-
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
|
784
|
-
</div>
|
|
785
|
-
)
|
|
786
|
-
}
|
|
787
|
-
|
|
788
1067
|
const fields = React.useMemo<CrudField[]>(() => [
|
|
789
1068
|
{
|
|
790
1069
|
id: 'documentKind',
|
|
@@ -826,7 +1105,7 @@ export function SalesDocumentForm({ onCreated, isSubmitting = false, initialKind
|
|
|
826
1105
|
label: t('sales.documents.form.number', 'Document number'),
|
|
827
1106
|
type: 'custom',
|
|
828
1107
|
required: true,
|
|
829
|
-
component: DocumentNumberField
|
|
1108
|
+
component: (props) => <DocumentNumberField {...props} t={t} />,
|
|
830
1109
|
},
|
|
831
1110
|
{
|
|
832
1111
|
id: 'currencyCode',
|
|
@@ -967,130 +1246,7 @@ export function SalesDocumentForm({ onCreated, isSubmitting = false, initialKind
|
|
|
967
1246
|
id: 'billingAddressSection',
|
|
968
1247
|
label: '',
|
|
969
1248
|
type: 'custom',
|
|
970
|
-
component:
|
|
971
|
-
const formValues = (values ?? {}) as Partial<SalesDocumentFormValues>
|
|
972
|
-
const updateValue = setFormValue ?? (() => {})
|
|
973
|
-
const useCustom = formValues.useCustomBilling === true
|
|
974
|
-
const selectedId = typeof formValues.billingAddressId === 'string' ? formValues.billingAddressId : ''
|
|
975
|
-
const draft = (formValues.billingAddressDraft ?? {}) as AddressDraft
|
|
976
|
-
const customerRequired = !formValues.customerEntityId
|
|
977
|
-
const sameAsShipping = formValues.sameAsShipping !== false
|
|
978
|
-
const shippingId = typeof formValues.shippingAddressId === 'string' ? formValues.shippingAddressId : null
|
|
979
|
-
const shippingDraft = (formValues.shippingAddressDraft ?? {}) as AddressDraft
|
|
980
|
-
const shippingDraftKey = JSON.stringify(shippingDraft)
|
|
981
|
-
const billingDraftKey = JSON.stringify(draft)
|
|
982
|
-
const useCustomShipping = formValues.useCustomShipping === true
|
|
983
|
-
|
|
984
|
-
React.useEffect(() => {
|
|
985
|
-
if (!sameAsShipping) return
|
|
986
|
-
if ((formValues.billingAddressId ?? null) !== shippingId) {
|
|
987
|
-
updateValue('billingAddressId', shippingId)
|
|
988
|
-
}
|
|
989
|
-
if (useCustomShipping !== (formValues.useCustomBilling === true)) {
|
|
990
|
-
updateValue('useCustomBilling', useCustomShipping)
|
|
991
|
-
}
|
|
992
|
-
if (useCustomShipping && shippingDraftKey !== billingDraftKey) {
|
|
993
|
-
updateValue('billingAddressDraft', shippingDraft)
|
|
994
|
-
}
|
|
995
|
-
}, [
|
|
996
|
-
billingDraftKey,
|
|
997
|
-
sameAsShipping,
|
|
998
|
-
updateValue,
|
|
999
|
-
shippingDraft,
|
|
1000
|
-
shippingDraftKey,
|
|
1001
|
-
shippingId,
|
|
1002
|
-
useCustomShipping,
|
|
1003
|
-
formValues.billingAddressId,
|
|
1004
|
-
formValues.useCustomBilling,
|
|
1005
|
-
])
|
|
1006
|
-
|
|
1007
|
-
return (
|
|
1008
|
-
<div className="space-y-3">
|
|
1009
|
-
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
|
1010
|
-
<div>
|
|
1011
|
-
<p className="text-base font-semibold">
|
|
1012
|
-
{t('sales.documents.form.billing.title', 'Billing address')}
|
|
1013
|
-
</p>
|
|
1014
|
-
<p className="text-xs text-muted-foreground">
|
|
1015
|
-
{sameAsShipping
|
|
1016
|
-
? t('sales.documents.form.address.sameAsShippingHint', 'Billing will mirror the shipping address. Uncheck to edit.')
|
|
1017
|
-
: t('sales.documents.form.billing.hint', 'Select an address or define a new one.')}
|
|
1018
|
-
</p>
|
|
1019
|
-
</div>
|
|
1020
|
-
<SwitchField
|
|
1021
|
-
label={t('sales.documents.form.address.sameAsShipping', 'Same as shipping')}
|
|
1022
|
-
flip
|
|
1023
|
-
checked={sameAsShipping}
|
|
1024
|
-
onCheckedChange={(checked) => {
|
|
1025
|
-
updateValue('sameAsShipping', checked)
|
|
1026
|
-
if (checked) {
|
|
1027
|
-
updateValue('useCustomBilling', useCustomShipping)
|
|
1028
|
-
updateValue('billingAddressId', shippingId)
|
|
1029
|
-
updateValue('billingAddressDraft', shippingDraft)
|
|
1030
|
-
}
|
|
1031
|
-
}}
|
|
1032
|
-
/>
|
|
1033
|
-
</div>
|
|
1034
|
-
|
|
1035
|
-
{!sameAsShipping ? (
|
|
1036
|
-
<>
|
|
1037
|
-
{!useCustom ? (
|
|
1038
|
-
<Select
|
|
1039
|
-
value={selectedId || undefined}
|
|
1040
|
-
onValueChange={(value) => updateValue('billingAddressId', value || null)}
|
|
1041
|
-
disabled={addressesLoading || customerRequired}
|
|
1042
|
-
>
|
|
1043
|
-
<SelectTrigger>
|
|
1044
|
-
<SelectValue
|
|
1045
|
-
placeholder={
|
|
1046
|
-
addressesLoading
|
|
1047
|
-
? t('sales.documents.form.address.loading', 'Loading addresses…')
|
|
1048
|
-
: t('sales.documents.form.address.placeholder', 'Select address')
|
|
1049
|
-
}
|
|
1050
|
-
/>
|
|
1051
|
-
</SelectTrigger>
|
|
1052
|
-
<SelectContent>
|
|
1053
|
-
{addressOptions.map((addr) => {
|
|
1054
|
-
const optionLabel = addr.summary ? `${addr.label} — ${addr.summary}` : addr.label
|
|
1055
|
-
return (
|
|
1056
|
-
<SelectItem key={addr.id} value={addr.id}>{optionLabel}</SelectItem>
|
|
1057
|
-
)
|
|
1058
|
-
})}
|
|
1059
|
-
</SelectContent>
|
|
1060
|
-
</Select>
|
|
1061
|
-
) : null}
|
|
1062
|
-
|
|
1063
|
-
<SwitchField
|
|
1064
|
-
label={t('sales.documents.form.shipping.custom', 'Define new address')}
|
|
1065
|
-
flip
|
|
1066
|
-
checked={useCustom}
|
|
1067
|
-
onCheckedChange={(checked) => updateValue('useCustomBilling', checked)}
|
|
1068
|
-
disabled={false}
|
|
1069
|
-
/>
|
|
1070
|
-
|
|
1071
|
-
{useCustom ? (
|
|
1072
|
-
<div className="space-y-3">
|
|
1073
|
-
<AddressEditor
|
|
1074
|
-
value={draft}
|
|
1075
|
-
format={addressFormat}
|
|
1076
|
-
t={t}
|
|
1077
|
-
onChange={(next) => updateValue('billingAddressDraft', next)}
|
|
1078
|
-
hidePrimaryToggle
|
|
1079
|
-
/>
|
|
1080
|
-
<SwitchField
|
|
1081
|
-
containerClassName="col-span-2"
|
|
1082
|
-
label={t('sales.documents.form.address.saveToCustomer', 'Save this address to the customer')}
|
|
1083
|
-
flip
|
|
1084
|
-
checked={formValues.saveBillingAddress === true}
|
|
1085
|
-
onCheckedChange={(checked) => updateValue('saveBillingAddress', checked)}
|
|
1086
|
-
/>
|
|
1087
|
-
</div>
|
|
1088
|
-
) : null}
|
|
1089
|
-
</>
|
|
1090
|
-
) : null}
|
|
1091
|
-
</div>
|
|
1092
|
-
)
|
|
1093
|
-
},
|
|
1249
|
+
component: (props) => <BillingAddressSectionField {...props} t={t} addressesLoading={addressesLoading} addressOptions={addressOptions} addressFormat={addressFormat} />,
|
|
1094
1250
|
},
|
|
1095
1251
|
{
|
|
1096
1252
|
id: 'comments',
|
|
@@ -1128,139 +1284,7 @@ export function SalesDocumentForm({ onCreated, isSubmitting = false, initialKind
|
|
|
1128
1284
|
title: '',
|
|
1129
1285
|
column: 1,
|
|
1130
1286
|
fields: [],
|
|
1131
|
-
component:
|
|
1132
|
-
const emailValue = typeof values.customerEmail === 'string' ? values.customerEmail : ''
|
|
1133
|
-
const { duplicate, checking } = useEmailDuplicateCheck(emailValue, {
|
|
1134
|
-
disabled: false,
|
|
1135
|
-
debounceMs: 400,
|
|
1136
|
-
matchMode: 'prefix',
|
|
1137
|
-
})
|
|
1138
|
-
return (
|
|
1139
|
-
<div className="space-y-4">
|
|
1140
|
-
<div className="space-y-3">
|
|
1141
|
-
<LookupSelect
|
|
1142
|
-
value={typeof values.customerEntityId === 'string' ? values.customerEntityId : null}
|
|
1143
|
-
onChange={(next) => {
|
|
1144
|
-
if (next !== values.customerEntityId) {
|
|
1145
|
-
resetAddressFormState(setValue)
|
|
1146
|
-
}
|
|
1147
|
-
setValue('customerEntityId', next)
|
|
1148
|
-
loadAddresses(next).then((addrs) => {
|
|
1149
|
-
const primary = addrs.find((addr) => addr.isPrimary)
|
|
1150
|
-
if (primary) {
|
|
1151
|
-
setValue('shippingAddressId', primary.id)
|
|
1152
|
-
}
|
|
1153
|
-
}).catch((err) => { console.error('sales.documents.autoSelectAddress', err) })
|
|
1154
|
-
if (next) {
|
|
1155
|
-
const match = customers.find((entry) => entry.id === next)
|
|
1156
|
-
const possibleEmail =
|
|
1157
|
-
typeof match?.primaryEmail === 'string' && match.primaryEmail.length
|
|
1158
|
-
? match.primaryEmail
|
|
1159
|
-
: null
|
|
1160
|
-
if (possibleEmail) {
|
|
1161
|
-
setValue('customerEmail', possibleEmail)
|
|
1162
|
-
} else {
|
|
1163
|
-
fetchCustomerEmail(next, match?.kind)
|
|
1164
|
-
.then((email) => {
|
|
1165
|
-
if (email) setValue('customerEmail', email)
|
|
1166
|
-
})
|
|
1167
|
-
.catch(() => {})
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1170
|
-
}}
|
|
1171
|
-
fetchItems={async (query) => {
|
|
1172
|
-
const options = await loadCustomers(query)
|
|
1173
|
-
return options.map<LookupSelectItem>((opt) => ({
|
|
1174
|
-
id: opt.id,
|
|
1175
|
-
title: opt.label,
|
|
1176
|
-
subtitle: opt.subtitle ?? undefined,
|
|
1177
|
-
icon:
|
|
1178
|
-
opt.kind === 'person' ? (
|
|
1179
|
-
<UserRound className="h-5 w-5 text-muted-foreground" />
|
|
1180
|
-
) : (
|
|
1181
|
-
<Building2 className="h-5 w-5 text-muted-foreground" />
|
|
1182
|
-
),
|
|
1183
|
-
}))
|
|
1184
|
-
}}
|
|
1185
|
-
actionSlot={
|
|
1186
|
-
<CustomerQuickCreate
|
|
1187
|
-
t={t}
|
|
1188
|
-
onCreated={({ id, email, label, kind, subtitle }) => {
|
|
1189
|
-
// Seed the search box with the new customer's display name so it renders immediately
|
|
1190
|
-
customerQuerySetter.current?.(label)
|
|
1191
|
-
setCustomers((prev) => {
|
|
1192
|
-
const exists = prev.some((entry) => entry.id === id)
|
|
1193
|
-
if (exists) return prev
|
|
1194
|
-
const next: CustomerOption = {
|
|
1195
|
-
id,
|
|
1196
|
-
label,
|
|
1197
|
-
subtitle: subtitle ?? undefined,
|
|
1198
|
-
kind,
|
|
1199
|
-
primaryEmail: email ?? null,
|
|
1200
|
-
}
|
|
1201
|
-
return [next, ...prev]
|
|
1202
|
-
})
|
|
1203
|
-
setValue('customerEntityId', id)
|
|
1204
|
-
resetAddressFormState(setValue)
|
|
1205
|
-
loadAddresses(id).then((addrs) => {
|
|
1206
|
-
const primary = addrs.find((addr) => addr.isPrimary)
|
|
1207
|
-
if (primary) {
|
|
1208
|
-
setValue('shippingAddressId', primary.id)
|
|
1209
|
-
}
|
|
1210
|
-
}).catch((err) => { console.error('sales.documents.autoSelectAddress', err) })
|
|
1211
|
-
if (email && !values.customerEmail) {
|
|
1212
|
-
setValue('customerEmail', email)
|
|
1213
|
-
}
|
|
1214
|
-
}}
|
|
1215
|
-
/>
|
|
1216
|
-
}
|
|
1217
|
-
onReady={({ setQuery }) => {
|
|
1218
|
-
customerQuerySetter.current = setQuery
|
|
1219
|
-
}}
|
|
1220
|
-
searchPlaceholder={t('sales.documents.form.customer.placeholder', 'Search customers…')}
|
|
1221
|
-
loadingLabel={t('sales.documents.form.customer.loading', 'Loading customers…')}
|
|
1222
|
-
emptyLabel={t('sales.documents.form.customer.empty', 'No customers found.')}
|
|
1223
|
-
selectedHintLabel={(id) => t('sales.documents.form.customer.selected', 'Selected customer: {{id}}', { id })}
|
|
1224
|
-
/>
|
|
1225
|
-
</div>
|
|
1226
|
-
<div className="space-y-2">
|
|
1227
|
-
<EmailInput
|
|
1228
|
-
value={emailValue}
|
|
1229
|
-
onChange={(event) => setValue('customerEmail', event.target.value)}
|
|
1230
|
-
placeholder={t('sales.documents.form.email.placeholder', 'Email used for the document')}
|
|
1231
|
-
spellCheck={false}
|
|
1232
|
-
/>
|
|
1233
|
-
{duplicate ? (
|
|
1234
|
-
<div className="flex items-center justify-between rounded border bg-muted px-3 py-2 text-xs text-muted-foreground">
|
|
1235
|
-
<span>
|
|
1236
|
-
{t('customers.people.form.emailDuplicateNotice', undefined, { name: duplicate.displayName })}
|
|
1237
|
-
</span>
|
|
1238
|
-
<Button
|
|
1239
|
-
size="sm"
|
|
1240
|
-
variant="secondary"
|
|
1241
|
-
className="px-4"
|
|
1242
|
-
type="button"
|
|
1243
|
-
disabled={values.customerEntityId === duplicate.id}
|
|
1244
|
-
aria-disabled={values.customerEntityId === duplicate.id}
|
|
1245
|
-
onClick={() => {
|
|
1246
|
-
setValue('customerEntityId', duplicate.id)
|
|
1247
|
-
resetAddressFormState(setValue)
|
|
1248
|
-
loadAddresses(duplicate.id)
|
|
1249
|
-
}}
|
|
1250
|
-
>
|
|
1251
|
-
{values.customerEntityId === duplicate.id
|
|
1252
|
-
? t('sales.documents.form.email.alreadySelected', 'Selected customer')
|
|
1253
|
-
: t('sales.documents.form.email.selectCustomer', 'Select customer')}
|
|
1254
|
-
</Button>
|
|
1255
|
-
</div>
|
|
1256
|
-
) : null}
|
|
1257
|
-
{!duplicate && checking ? (
|
|
1258
|
-
<p className="text-xs text-muted-foreground">{t('customers.people.form.emailChecking')}</p>
|
|
1259
|
-
) : null}
|
|
1260
|
-
</div>
|
|
1261
|
-
</div>
|
|
1262
|
-
)
|
|
1263
|
-
},
|
|
1287
|
+
component: (ctx) => <CustomerGroupComponent {...ctx} t={t} customers={customers} setCustomers={setCustomers} customerQuerySetter={customerQuerySetter} loadAddresses={loadAddresses} loadCustomers={loadCustomers} fetchCustomerEmail={fetchCustomerEmail} resetAddressFormState={resetAddressFormState} />,
|
|
1264
1288
|
},
|
|
1265
1289
|
{ id: 'channels-comments', title: '', column: 1, fields: ['channelId', 'comments'] },
|
|
1266
1290
|
{ id: 'currency', title: '', column: 2, fields: ['currencyCode'] },
|