@open-mercato/core 0.6.6-develop.6184.1.b7e55f8d61 → 0.6.6-develop.6201.1.8ceb502c4b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/frontend/login.js +5 -5
  3. package/dist/modules/auth/frontend/login.js.map +2 -2
  4. package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js +3 -2
  5. package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js.map +2 -2
  6. package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js +26 -14
  7. package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js.map +2 -2
  8. package/dist/modules/customers/api/deals/[id]/route.js +169 -149
  9. package/dist/modules/customers/api/deals/[id]/route.js.map +3 -3
  10. package/dist/modules/customers/components/detail/InlineEditors.js +9 -4
  11. package/dist/modules/customers/components/detail/InlineEditors.js.map +2 -2
  12. package/dist/modules/integrations/backend/integrations/[id]/page.js +13 -9
  13. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  14. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +81 -37
  15. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  16. package/dist/modules/integrations/backend/integrations/detail-page-refresh.js +17 -0
  17. package/dist/modules/integrations/backend/integrations/detail-page-refresh.js.map +7 -0
  18. package/dist/modules/integrations/backend/integrations/page.js +45 -24
  19. package/dist/modules/integrations/backend/integrations/page.js.map +2 -2
  20. package/dist/modules/notifications/api/[id]/action/route.js +18 -3
  21. package/dist/modules/notifications/api/[id]/action/route.js.map +2 -2
  22. package/dist/modules/notifications/api/[id]/restore/route.js +19 -3
  23. package/dist/modules/notifications/api/[id]/restore/route.js.map +2 -2
  24. package/dist/modules/notifications/api/mark-all-read/route.js +18 -4
  25. package/dist/modules/notifications/api/mark-all-read/route.js.map +2 -2
  26. package/dist/modules/notifications/api/route.js +17 -4
  27. package/dist/modules/notifications/api/route.js.map +2 -2
  28. package/dist/modules/notifications/api/settings/route.js +26 -5
  29. package/dist/modules/notifications/api/settings/route.js.map +2 -2
  30. package/dist/modules/notifications/lib/routeHelpers.js +64 -5
  31. package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
  32. package/dist/modules/sales/components/documents/SalesDocumentForm.js +299 -297
  33. package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
  34. package/dist/modules/workflows/components/EdgeEditDialog.js +120 -141
  35. package/dist/modules/workflows/components/EdgeEditDialog.js.map +2 -2
  36. package/dist/modules/workflows/components/NodeEditDialog.js +114 -118
  37. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  38. package/dist/modules/workflows/frontend/checkout-demo/page.js +155 -260
  39. package/dist/modules/workflows/frontend/checkout-demo/page.js.map +2 -2
  40. package/package.json +7 -7
  41. package/src/modules/auth/frontend/login.tsx +5 -5
  42. package/src/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.tsx +4 -3
  43. package/src/modules/catalog/widgets/injection/product-seo/widget.client.tsx +27 -17
  44. package/src/modules/customers/api/deals/[id]/route.ts +208 -174
  45. package/src/modules/customers/components/detail/InlineEditors.tsx +6 -10
  46. package/src/modules/integrations/backend/integrations/[id]/page.tsx +13 -9
  47. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +78 -35
  48. package/src/modules/integrations/backend/integrations/detail-page-refresh.ts +36 -0
  49. package/src/modules/integrations/backend/integrations/page.tsx +42 -21
  50. package/src/modules/notifications/api/[id]/action/route.ts +17 -2
  51. package/src/modules/notifications/api/[id]/restore/route.ts +19 -3
  52. package/src/modules/notifications/api/mark-all-read/route.ts +18 -4
  53. package/src/modules/notifications/api/route.ts +16 -3
  54. package/src/modules/notifications/api/settings/route.ts +26 -5
  55. package/src/modules/notifications/lib/routeHelpers.ts +101 -4
  56. package/src/modules/sales/components/documents/SalesDocumentForm.tsx +351 -327
  57. package/src/modules/workflows/components/EdgeEditDialog.tsx +132 -157
  58. package/src/modules/workflows/components/NodeEditDialog.tsx +116 -121
  59. package/src/modules/workflows/frontend/checkout-demo/page.tsx +172 -239
  60. package/src/modules/workflows/i18n/de.json +2 -0
  61. package/src/modules/workflows/i18n/en.json +2 -0
  62. package/src/modules/workflows/i18n/es.json +2 -0
  63. 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: function BillingAddressSectionField({ values, setFormValue }) {
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: function CustomerGroupComponent({ values, setValue }) {
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'] },