@open-mercato/core 0.6.3-develop.3894.1.352abf4240 → 0.6.3
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/global.d.js +1 -0
- package/dist/global.d.js.map +7 -0
- package/dist/modules/catalog/commands/variants.js +11 -5
- package/dist/modules/catalog/commands/variants.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/create/page.js +3 -61
- package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
- package/dist/modules/customers/components/detail/DealForm.js +2 -0
- package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
- package/dist/modules/customers/components/detail/create/CreateDealForm.js +233 -0
- package/dist/modules/customers/components/detail/create/CreateDealForm.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealAssociationsField.js +209 -0
- package/dist/modules/customers/components/detail/create/DealAssociationsField.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealAssociationsSection.js +67 -0
- package/dist/modules/customers/components/detail/create/DealAssociationsSection.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealCreateSidebar.js +73 -0
- package/dist/modules/customers/components/detail/create/DealCreateSidebar.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealCurrencyField.js +92 -0
- package/dist/modules/customers/components/detail/create/DealCurrencyField.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealCustomAttributes.js +81 -0
- package/dist/modules/customers/components/detail/create/DealCustomAttributes.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealDetailsFields.js +171 -0
- package/dist/modules/customers/components/detail/create/DealDetailsFields.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealFormField.js +24 -0
- package/dist/modules/customers/components/detail/create/DealFormField.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealSectionCard.js +29 -0
- package/dist/modules/customers/components/detail/create/DealSectionCard.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealTipsCard.js +19 -0
- package/dist/modules/customers/components/detail/create/DealTipsCard.js.map +7 -0
- package/dist/modules/customers/components/detail/create/PipelineSelect.js +41 -0
- package/dist/modules/customers/components/detail/create/PipelineSelect.js.map +7 -0
- package/dist/modules/customers/components/detail/create/PipelineStageSelect.js +49 -0
- package/dist/modules/customers/components/detail/create/PipelineStageSelect.js.map +7 -0
- package/dist/modules/customers/components/detail/create/SuffixInput.js +21 -0
- package/dist/modules/customers/components/detail/create/SuffixInput.js.map +7 -0
- package/dist/modules/customers/components/detail/create/dealCustomFieldControl.js +270 -0
- package/dist/modules/customers/components/detail/create/dealCustomFieldControl.js.map +7 -0
- package/dist/modules/customers/components/detail/create/dealFormTypes.js +17 -0
- package/dist/modules/customers/components/detail/create/dealFormTypes.js.map +7 -0
- package/dist/modules/customers/components/detail/create/dealNumericInput.js +16 -0
- package/dist/modules/customers/components/detail/create/dealNumericInput.js.map +7 -0
- package/dist/modules/customers/components/detail/create/useDealCustomFields.js +93 -0
- package/dist/modules/customers/components/detail/create/useDealCustomFields.js.map +7 -0
- package/dist/modules/customers/components/detail/create/useDealPipelines.js +59 -0
- package/dist/modules/customers/components/detail/create/useDealPipelines.js.map +7 -0
- package/dist/modules/customers/components/formConfig.js +4 -2
- package/dist/modules/customers/components/formConfig.js.map +2 -2
- package/dist/modules/dictionaries/components/DictionaryEntrySelect.js +5 -2
- package/dist/modules/dictionaries/components/DictionaryEntrySelect.js.map +2 -2
- package/dist/modules/feature_toggles/lib/feature-flag-check.js +13 -5
- package/dist/modules/feature_toggles/lib/feature-flag-check.js.map +2 -2
- package/dist/modules/query_index/subscribers/coverage_refresh.js +6 -1
- package/dist/modules/query_index/subscribers/coverage_refresh.js.map +2 -2
- package/dist/modules/sync_excel/widgets/injection/upload-config/target-options.js +33 -5
- package/dist/modules/sync_excel/widgets/injection/upload-config/target-options.js.map +2 -2
- package/dist/modules/workflows/components/WorkflowGraph.js +29 -186
- package/dist/modules/workflows/components/WorkflowGraph.js.map +2 -2
- package/dist/modules/workflows/components/WorkflowGraphImpl.js +196 -0
- package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +7 -0
- package/package.json +8 -9
- package/src/global.d.ts +9 -0
- package/src/modules/catalog/commands/variants.ts +14 -5
- package/src/modules/customers/backend/customers/deals/create/page.tsx +3 -64
- package/src/modules/customers/components/detail/DealForm.tsx +2 -0
- package/src/modules/customers/components/detail/create/CreateDealForm.tsx +254 -0
- package/src/modules/customers/components/detail/create/DealAssociationsField.tsx +253 -0
- package/src/modules/customers/components/detail/create/DealAssociationsSection.tsx +72 -0
- package/src/modules/customers/components/detail/create/DealCreateSidebar.tsx +79 -0
- package/src/modules/customers/components/detail/create/DealCurrencyField.tsx +108 -0
- package/src/modules/customers/components/detail/create/DealCustomAttributes.tsx +118 -0
- package/src/modules/customers/components/detail/create/DealDetailsFields.tsx +171 -0
- package/src/modules/customers/components/detail/create/DealFormField.tsx +39 -0
- package/src/modules/customers/components/detail/create/DealSectionCard.tsx +40 -0
- package/src/modules/customers/components/detail/create/DealTipsCard.tsx +26 -0
- package/src/modules/customers/components/detail/create/PipelineSelect.tsx +55 -0
- package/src/modules/customers/components/detail/create/PipelineStageSelect.tsx +70 -0
- package/src/modules/customers/components/detail/create/SuffixInput.tsx +20 -0
- package/src/modules/customers/components/detail/create/dealCustomFieldControl.tsx +310 -0
- package/src/modules/customers/components/detail/create/dealFormTypes.ts +29 -0
- package/src/modules/customers/components/detail/create/dealNumericInput.ts +20 -0
- package/src/modules/customers/components/detail/create/useDealCustomFields.ts +118 -0
- package/src/modules/customers/components/detail/create/useDealPipelines.ts +80 -0
- package/src/modules/customers/components/formConfig.tsx +3 -0
- package/src/modules/customers/i18n/de.json +26 -0
- package/src/modules/customers/i18n/en.json +26 -0
- package/src/modules/customers/i18n/es.json +26 -0
- package/src/modules/customers/i18n/pl.json +26 -0
- package/src/modules/dictionaries/components/DictionaryEntrySelect.tsx +12 -1
- package/src/modules/feature_toggles/lib/feature-flag-check.ts +14 -4
- package/src/modules/query_index/subscribers/coverage_refresh.ts +7 -1
- package/src/modules/resources/i18n/de.json +1 -0
- package/src/modules/resources/i18n/en.json +1 -0
- package/src/modules/resources/i18n/es.json +1 -0
- package/src/modules/resources/i18n/pl.json +1 -0
- package/src/modules/sales/i18n/de.json +2 -0
- package/src/modules/sales/i18n/en.json +2 -0
- package/src/modules/sales/i18n/es.json +2 -0
- package/src/modules/sales/i18n/pl.json +2 -0
- package/src/modules/sync_excel/widgets/injection/upload-config/target-options.ts +40 -5
- package/src/modules/workflows/components/WorkflowGraph.tsx +39 -235
- package/src/modules/workflows/components/WorkflowGraphImpl.tsx +233 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Sparkles } from 'lucide-react'
|
|
5
|
+
import { DealSectionCard } from './DealSectionCard'
|
|
6
|
+
import { DealTipsCard } from './DealTipsCard'
|
|
7
|
+
import {
|
|
8
|
+
DealCustomAttributes,
|
|
9
|
+
type DealCustomAttributesLoadState,
|
|
10
|
+
} from './DealCustomAttributes'
|
|
11
|
+
import type { Translate } from './dealFormTypes'
|
|
12
|
+
|
|
13
|
+
export type DealCreateSidebarProps = {
|
|
14
|
+
tr: Translate
|
|
15
|
+
customValues: Record<string, unknown>
|
|
16
|
+
onCustomChange: (key: string, value: unknown) => void
|
|
17
|
+
errors: Record<string, string>
|
|
18
|
+
disabled: boolean
|
|
19
|
+
customCount: number
|
|
20
|
+
manageHref: string
|
|
21
|
+
onCustomLoaded: (state: DealCustomAttributesLoadState) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function DealCreateSidebar({
|
|
25
|
+
tr,
|
|
26
|
+
customValues,
|
|
27
|
+
onCustomChange,
|
|
28
|
+
errors,
|
|
29
|
+
disabled,
|
|
30
|
+
customCount,
|
|
31
|
+
manageHref,
|
|
32
|
+
onCustomLoaded,
|
|
33
|
+
}: DealCreateSidebarProps) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="space-y-4">
|
|
36
|
+
<DealSectionCard
|
|
37
|
+
icon={Sparkles}
|
|
38
|
+
title={tr('customers.deals.create.sections.custom.title', 'Custom attributes')}
|
|
39
|
+
subtitle={tr('customers.deals.create.sections.custom.subtitle', '{count} fields defined for this tenant', {
|
|
40
|
+
count: customCount,
|
|
41
|
+
})}
|
|
42
|
+
>
|
|
43
|
+
<DealCustomAttributes
|
|
44
|
+
values={customValues}
|
|
45
|
+
onChange={onCustomChange}
|
|
46
|
+
errors={errors}
|
|
47
|
+
disabled={disabled}
|
|
48
|
+
manageHref={manageHref}
|
|
49
|
+
labels={{
|
|
50
|
+
manage: tr('customers.deals.create.sections.custom.manage', 'Manage fields'),
|
|
51
|
+
empty: tr('customers.deals.create.sections.custom.empty', 'No custom fields defined for deals yet.'),
|
|
52
|
+
loading: tr('customers.deals.create.sections.custom.loading', 'Loading custom fields…'),
|
|
53
|
+
}}
|
|
54
|
+
onLoaded={onCustomLoaded}
|
|
55
|
+
/>
|
|
56
|
+
</DealSectionCard>
|
|
57
|
+
|
|
58
|
+
<DealTipsCard
|
|
59
|
+
title={tr('customers.deals.create.tips.title', 'Tips for better deals')}
|
|
60
|
+
tips={[
|
|
61
|
+
tr(
|
|
62
|
+
'customers.deals.create.tips.item1',
|
|
63
|
+
'Use the company name + short deliverable format in the title (e.g. "Copperleaf — Q3 Renewal")',
|
|
64
|
+
),
|
|
65
|
+
tr(
|
|
66
|
+
'customers.deals.create.tips.item2',
|
|
67
|
+
'Set probability based on pipeline stage: Qual 10-25%, Proposal 30-50%, Negotiation 50-75%, Contract 75-90%',
|
|
68
|
+
),
|
|
69
|
+
tr(
|
|
70
|
+
'customers.deals.create.tips.item3',
|
|
71
|
+
'Link primary decision maker as first person — they get default email CC on activities',
|
|
72
|
+
),
|
|
73
|
+
]}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default DealCreateSidebar
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { DictionaryEntrySelect } from '@open-mercato/core/modules/dictionaries/components/DictionaryEntrySelect'
|
|
7
|
+
import { useCurrencyDictionary } from '../hooks/useCurrencyDictionary'
|
|
8
|
+
|
|
9
|
+
const CURRENCY_PRIORITY = ['EUR', 'USD', 'GBP', 'PLN'] as const
|
|
10
|
+
|
|
11
|
+
export type DealCurrencyFieldProps = {
|
|
12
|
+
id?: string
|
|
13
|
+
value: string
|
|
14
|
+
onChange: (code: string) => void
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function DealCurrencyField({ id, value, onChange, disabled = false }: DealCurrencyFieldProps) {
|
|
19
|
+
const t = useT()
|
|
20
|
+
const { data, error: rawError, isLoading, refetch } = useCurrencyDictionary()
|
|
21
|
+
const dictError = rawError
|
|
22
|
+
? rawError instanceof Error
|
|
23
|
+
? rawError.message
|
|
24
|
+
: String(rawError)
|
|
25
|
+
: null
|
|
26
|
+
|
|
27
|
+
const resolvedError = React.useMemo(() => {
|
|
28
|
+
if (dictError) return dictError
|
|
29
|
+
if (!isLoading && !data) {
|
|
30
|
+
return t('customers.deals.form.currency.missing', 'Currency dictionary is not configured yet.')
|
|
31
|
+
}
|
|
32
|
+
return null
|
|
33
|
+
}, [data, dictError, isLoading, t])
|
|
34
|
+
|
|
35
|
+
const fetchOptions = React.useCallback(async () => {
|
|
36
|
+
let payload = data ?? null
|
|
37
|
+
if (!payload) {
|
|
38
|
+
try {
|
|
39
|
+
payload = await refetch()
|
|
40
|
+
} catch (err) {
|
|
41
|
+
const message = err instanceof Error ? err.message : String(err ?? '')
|
|
42
|
+
throw new Error(message || t('customers.deals.form.currency.error', 'Failed to load currency dictionary.'))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!payload) {
|
|
46
|
+
throw new Error(t('customers.deals.form.currency.missing', 'Currency dictionary is not configured yet.'))
|
|
47
|
+
}
|
|
48
|
+
const priorityOrder = new Map<string, number>()
|
|
49
|
+
CURRENCY_PRIORITY.forEach((code, index) => priorityOrder.set(code, index))
|
|
50
|
+
const prioritized: { value: string; label: string; color: string | null; icon: string | null }[] = []
|
|
51
|
+
const remainder: { value: string; label: string; color: string | null; icon: string | null }[] = []
|
|
52
|
+
payload.entries.forEach((entry) => {
|
|
53
|
+
const code = entry.value.toUpperCase()
|
|
54
|
+
const label = entry.label && entry.label.length ? `${code} – ${entry.label}` : code
|
|
55
|
+
const option = { value: code, label, color: null, icon: null }
|
|
56
|
+
if (priorityOrder.has(code)) prioritized.push(option)
|
|
57
|
+
else remainder.push(option)
|
|
58
|
+
})
|
|
59
|
+
prioritized.sort((a, b) => priorityOrder.get(a.value)! - priorityOrder.get(b.value)!)
|
|
60
|
+
remainder.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }))
|
|
61
|
+
return [...prioritized, ...remainder]
|
|
62
|
+
}, [data, refetch, t])
|
|
63
|
+
|
|
64
|
+
const labels = React.useMemo(
|
|
65
|
+
() => ({
|
|
66
|
+
placeholder: t('customers.deals.form.currency.placeholder', 'Select currency…'),
|
|
67
|
+
addLabel: t('customers.deals.form.currency.add', 'Add currency'),
|
|
68
|
+
dialogTitle: t('customers.deals.form.currency.dialogTitle', 'Add currency'),
|
|
69
|
+
valueLabel: t('customers.deals.form.currency.valueLabel', 'Currency code'),
|
|
70
|
+
valuePlaceholder: t('customers.deals.form.currency.valuePlaceholder', 'e.g. USD'),
|
|
71
|
+
labelLabel: t('customers.deals.form.currency.labelLabel', 'Label'),
|
|
72
|
+
labelPlaceholder: t('customers.deals.form.currency.labelPlaceholder', 'Display name shown in UI'),
|
|
73
|
+
emptyError: t('customers.deals.form.currency.error.required', 'Currency code is required.'),
|
|
74
|
+
cancelLabel: t('customers.deals.form.currency.cancel', 'Cancel'),
|
|
75
|
+
saveLabel: t('customers.deals.form.currency.save', 'Save'),
|
|
76
|
+
errorLoad: t('customers.deals.form.currency.error', 'Failed to load currency dictionary.'),
|
|
77
|
+
errorSave: t('customers.deals.form.currency.error', 'Failed to load currency dictionary.'),
|
|
78
|
+
loadingLabel: t('customers.deals.form.currency.loading', 'Loading currencies…'),
|
|
79
|
+
manageTitle: t('customers.deals.form.currency.manage', 'Manage currency dictionary'),
|
|
80
|
+
}),
|
|
81
|
+
[t],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="space-y-1">
|
|
86
|
+
<DictionaryEntrySelect
|
|
87
|
+
id={id}
|
|
88
|
+
value={value || undefined}
|
|
89
|
+
onChange={(next) => onChange(next ?? '')}
|
|
90
|
+
fetchOptions={fetchOptions}
|
|
91
|
+
labels={labels}
|
|
92
|
+
manageHref="/backend/config/dictionaries?key=currency"
|
|
93
|
+
allowInlineCreate={false}
|
|
94
|
+
allowAppearance={false}
|
|
95
|
+
selectClassName="w-full"
|
|
96
|
+
disabled={disabled}
|
|
97
|
+
showLabelInput={false}
|
|
98
|
+
/>
|
|
99
|
+
{resolvedError ? (
|
|
100
|
+
<p className={cn('text-xs', dictError ? 'text-status-error-text' : 'text-muted-foreground')}>
|
|
101
|
+
{resolvedError}
|
|
102
|
+
</p>
|
|
103
|
+
) : null}
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export default DealCurrencyField
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Plus } from 'lucide-react'
|
|
5
|
+
import type { CrudField } from '@open-mercato/ui/backend/CrudForm'
|
|
6
|
+
import type { CustomFieldDefDto } from '@open-mercato/ui/backend/utils/customFieldDefs'
|
|
7
|
+
import { fetchCustomFieldFormStructure } from '@open-mercato/ui/backend/utils/customFieldForms'
|
|
8
|
+
import { LinkButton } from '@open-mercato/ui/primitives/link-button'
|
|
9
|
+
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
10
|
+
import { E } from '#generated/entities.ids.generated'
|
|
11
|
+
import { DealCustomFieldControl } from './dealCustomFieldControl'
|
|
12
|
+
|
|
13
|
+
export type DealCustomAttributesLoadState = {
|
|
14
|
+
fields: CrudField[]
|
|
15
|
+
definitions: CustomFieldDefDto[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type DealCustomAttributesProps = {
|
|
19
|
+
values: Record<string, unknown>
|
|
20
|
+
onChange: (key: string, value: unknown) => void
|
|
21
|
+
errors?: Record<string, string>
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
manageHref: string
|
|
24
|
+
labels: {
|
|
25
|
+
manage: string
|
|
26
|
+
empty: string
|
|
27
|
+
loading: string
|
|
28
|
+
}
|
|
29
|
+
onLoaded?: (state: DealCustomAttributesLoadState) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ManageFieldsLink({ href, label }: { href: string; label: string }) {
|
|
33
|
+
return (
|
|
34
|
+
<LinkButton asChild variant="gray" size="sm">
|
|
35
|
+
<a href={href} className="inline-flex items-center gap-1">
|
|
36
|
+
<Plus className="size-3.5" />
|
|
37
|
+
{label}
|
|
38
|
+
</a>
|
|
39
|
+
</LinkButton>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function DealCustomAttributes({
|
|
44
|
+
values,
|
|
45
|
+
onChange,
|
|
46
|
+
errors,
|
|
47
|
+
disabled = false,
|
|
48
|
+
manageHref,
|
|
49
|
+
labels,
|
|
50
|
+
onLoaded,
|
|
51
|
+
}: DealCustomAttributesProps) {
|
|
52
|
+
const [fields, setFields] = React.useState<CrudField[]>([])
|
|
53
|
+
const [isLoading, setIsLoading] = React.useState(true)
|
|
54
|
+
const onLoadedRef = React.useRef(onLoaded)
|
|
55
|
+
|
|
56
|
+
React.useEffect(() => {
|
|
57
|
+
onLoadedRef.current = onLoaded
|
|
58
|
+
}, [onLoaded])
|
|
59
|
+
|
|
60
|
+
React.useEffect(() => {
|
|
61
|
+
let cancelled = false
|
|
62
|
+
setIsLoading(true)
|
|
63
|
+
fetchCustomFieldFormStructure([E.customers.customer_deal])
|
|
64
|
+
.then((result) => {
|
|
65
|
+
if (cancelled) return
|
|
66
|
+
setFields(result.fields)
|
|
67
|
+
setIsLoading(false)
|
|
68
|
+
onLoadedRef.current?.({ fields: result.fields, definitions: result.definitions })
|
|
69
|
+
})
|
|
70
|
+
.catch(() => {
|
|
71
|
+
if (cancelled) return
|
|
72
|
+
setFields([])
|
|
73
|
+
setIsLoading(false)
|
|
74
|
+
onLoadedRef.current?.({ fields: [], definitions: [] })
|
|
75
|
+
})
|
|
76
|
+
return () => {
|
|
77
|
+
cancelled = true
|
|
78
|
+
}
|
|
79
|
+
}, [])
|
|
80
|
+
|
|
81
|
+
if (isLoading) {
|
|
82
|
+
return (
|
|
83
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
84
|
+
<Spinner className="size-4" />
|
|
85
|
+
{labels.loading}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (fields.length === 0) {
|
|
91
|
+
return (
|
|
92
|
+
<div className="space-y-3">
|
|
93
|
+
<p className="text-sm text-muted-foreground">{labels.empty}</p>
|
|
94
|
+
<ManageFieldsLink href={manageHref} label={labels.manage} />
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="space-y-4">
|
|
101
|
+
{fields.map((field) => (
|
|
102
|
+
<DealCustomFieldControl
|
|
103
|
+
key={field.id}
|
|
104
|
+
field={field}
|
|
105
|
+
value={values[field.id]}
|
|
106
|
+
onChange={(next) => onChange(field.id, next)}
|
|
107
|
+
error={errors?.[field.id]}
|
|
108
|
+
disabled={disabled}
|
|
109
|
+
/>
|
|
110
|
+
))}
|
|
111
|
+
<div className="pt-1">
|
|
112
|
+
<ManageFieldsLink href={manageHref} label={labels.manage} />
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default DealCustomAttributes
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { format } from 'date-fns/format'
|
|
5
|
+
import { parseISO } from 'date-fns/parseISO'
|
|
6
|
+
import { Input } from '@open-mercato/ui/primitives/input'
|
|
7
|
+
import { Textarea } from '@open-mercato/ui/primitives/textarea'
|
|
8
|
+
import { DatePicker } from '@open-mercato/ui/primitives/date-picker'
|
|
9
|
+
import { DictionarySelectField } from '../../formConfig'
|
|
10
|
+
import { createDictionarySelectLabels } from '../utils'
|
|
11
|
+
import { DealFormField } from './DealFormField'
|
|
12
|
+
import { PipelineSelect } from './PipelineSelect'
|
|
13
|
+
import { PipelineStageSelect } from './PipelineStageSelect'
|
|
14
|
+
import { SuffixInput } from './SuffixInput'
|
|
15
|
+
import { DealCurrencyField } from './DealCurrencyField'
|
|
16
|
+
import { sanitizeAmount, sanitizeProbability } from './dealNumericInput'
|
|
17
|
+
import type { BaseValues } from './dealFormTypes'
|
|
18
|
+
import type { PipelineOption, PipelineStageOption } from './useDealPipelines'
|
|
19
|
+
|
|
20
|
+
type Translate = (key: string, fallback: string, params?: Record<string, string | number>) => string
|
|
21
|
+
|
|
22
|
+
export type DealDetailsFieldsProps = {
|
|
23
|
+
values: BaseValues
|
|
24
|
+
errors: Record<string, string>
|
|
25
|
+
isSubmitting: boolean
|
|
26
|
+
patch: (partial: Partial<BaseValues>) => void
|
|
27
|
+
onPipelineChange: (id: string) => void
|
|
28
|
+
pipelines: PipelineOption[]
|
|
29
|
+
stages: PipelineStageOption[]
|
|
30
|
+
statusLabels: ReturnType<typeof createDictionarySelectLabels>
|
|
31
|
+
tr: Translate
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toDate(value: string): Date | null {
|
|
35
|
+
if (!value) return null
|
|
36
|
+
const parsed = parseISO(value)
|
|
37
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function DealDetailsFields({
|
|
41
|
+
values,
|
|
42
|
+
errors,
|
|
43
|
+
isSubmitting,
|
|
44
|
+
patch,
|
|
45
|
+
onPipelineChange,
|
|
46
|
+
pipelines,
|
|
47
|
+
stages,
|
|
48
|
+
statusLabels,
|
|
49
|
+
tr,
|
|
50
|
+
}: DealDetailsFieldsProps) {
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
<DealFormField
|
|
54
|
+
fieldId="title"
|
|
55
|
+
label={tr('customers.deals.create.fields.title', 'Deal title')}
|
|
56
|
+
required
|
|
57
|
+
hint={tr('customers.deals.create.hints.title', 'Short, descriptive name shown on pipeline cards')}
|
|
58
|
+
error={errors.title}
|
|
59
|
+
>
|
|
60
|
+
<Input
|
|
61
|
+
value={values.title}
|
|
62
|
+
onChange={(event) => patch({ title: event.target.value })}
|
|
63
|
+
aria-invalid={errors.title ? true : undefined}
|
|
64
|
+
disabled={isSubmitting}
|
|
65
|
+
/>
|
|
66
|
+
</DealFormField>
|
|
67
|
+
|
|
68
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
69
|
+
<DealFormField fieldId="status" label={tr('customers.people.detail.deals.fields.status', 'Status')}>
|
|
70
|
+
<DictionarySelectField
|
|
71
|
+
kind="deal-statuses"
|
|
72
|
+
value={values.status || undefined}
|
|
73
|
+
onChange={(next) => patch({ status: next ?? '' })}
|
|
74
|
+
labels={statusLabels}
|
|
75
|
+
selectClassName="w-full"
|
|
76
|
+
showActiveAppearance={false}
|
|
77
|
+
/>
|
|
78
|
+
</DealFormField>
|
|
79
|
+
<DealFormField fieldId="pipelineId" label={tr('customers.people.detail.deals.fields.pipeline', 'Pipeline')}>
|
|
80
|
+
<PipelineSelect
|
|
81
|
+
pipelines={pipelines}
|
|
82
|
+
value={values.pipelineId}
|
|
83
|
+
onChange={onPipelineChange}
|
|
84
|
+
disabled={isSubmitting}
|
|
85
|
+
placeholder={tr('customers.deals.form.pipeline.placeholder', 'Select pipeline…')}
|
|
86
|
+
/>
|
|
87
|
+
</DealFormField>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<DealFormField
|
|
91
|
+
fieldId="pipelineStageId"
|
|
92
|
+
label={tr('customers.people.detail.deals.fields.pipelineStage', 'Pipeline stage')}
|
|
93
|
+
hint={tr('customers.deals.create.hints.pipelineStage', 'Stages depend on the selected pipeline')}
|
|
94
|
+
>
|
|
95
|
+
<PipelineStageSelect
|
|
96
|
+
stages={stages}
|
|
97
|
+
value={values.pipelineStageId}
|
|
98
|
+
onChange={(id) => patch({ pipelineStageId: id })}
|
|
99
|
+
disabled={isSubmitting || !values.pipelineId}
|
|
100
|
+
placeholder={tr('customers.deals.form.pipelineStage.placeholder', 'Select stage…')}
|
|
101
|
+
formatCount={(position, total) =>
|
|
102
|
+
tr('customers.deals.create.fields.stageOf', '· stage {position} of {total}', { position, total })
|
|
103
|
+
}
|
|
104
|
+
/>
|
|
105
|
+
</DealFormField>
|
|
106
|
+
|
|
107
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
108
|
+
<DealFormField
|
|
109
|
+
fieldId="valueAmount"
|
|
110
|
+
label={tr('customers.deals.create.fields.valueAmount', 'Deal value')}
|
|
111
|
+
hint={tr('customers.deals.create.hints.valueAmount', 'Potential revenue from this opportunity')}
|
|
112
|
+
error={errors.valueAmount}
|
|
113
|
+
>
|
|
114
|
+
<SuffixInput
|
|
115
|
+
suffix={values.valueCurrency}
|
|
116
|
+
inputMode="decimal"
|
|
117
|
+
value={values.valueAmount}
|
|
118
|
+
onChange={(event) => patch({ valueAmount: sanitizeAmount(event.target.value) })}
|
|
119
|
+
placeholder="0"
|
|
120
|
+
aria-invalid={errors.valueAmount ? true : undefined}
|
|
121
|
+
disabled={isSubmitting}
|
|
122
|
+
/>
|
|
123
|
+
</DealFormField>
|
|
124
|
+
<DealFormField fieldId="valueCurrency" label={tr('customers.people.detail.deals.fields.valueCurrency', 'Currency')}>
|
|
125
|
+
<DealCurrencyField
|
|
126
|
+
value={values.valueCurrency}
|
|
127
|
+
onChange={(code) => patch({ valueCurrency: code })}
|
|
128
|
+
disabled={isSubmitting}
|
|
129
|
+
/>
|
|
130
|
+
</DealFormField>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
134
|
+
<DealFormField
|
|
135
|
+
fieldId="probability"
|
|
136
|
+
label={tr('customers.deals.create.fields.probability', 'Probability')}
|
|
137
|
+
hint={tr('customers.deals.create.hints.probability', '0 – 100%, used for weighted pipeline value')}
|
|
138
|
+
error={errors.probability}
|
|
139
|
+
>
|
|
140
|
+
<SuffixInput
|
|
141
|
+
suffix="%"
|
|
142
|
+
inputMode="numeric"
|
|
143
|
+
value={values.probability}
|
|
144
|
+
onChange={(event) => patch({ probability: sanitizeProbability(event.target.value) })}
|
|
145
|
+
placeholder="0"
|
|
146
|
+
aria-invalid={errors.probability ? true : undefined}
|
|
147
|
+
disabled={isSubmitting}
|
|
148
|
+
/>
|
|
149
|
+
</DealFormField>
|
|
150
|
+
<DealFormField fieldId="expectedCloseAt" label={tr('customers.deals.create.fields.expectedCloseAt', 'Expected close date')}>
|
|
151
|
+
<DatePicker
|
|
152
|
+
value={toDate(values.expectedCloseAt)}
|
|
153
|
+
onChange={(date) => patch({ expectedCloseAt: date ? format(date, 'yyyy-MM-dd') : '' })}
|
|
154
|
+
disabled={isSubmitting}
|
|
155
|
+
placeholder={tr('customers.deals.create.fields.datePlaceholder', 'Pick a date')}
|
|
156
|
+
/>
|
|
157
|
+
</DealFormField>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<DealFormField fieldId="description" label={tr('customers.people.detail.deals.fields.description', 'Description')}>
|
|
161
|
+
<Textarea
|
|
162
|
+
value={values.description}
|
|
163
|
+
onChange={(event) => patch({ description: event.target.value })}
|
|
164
|
+
disabled={isSubmitting}
|
|
165
|
+
/>
|
|
166
|
+
</DealFormField>
|
|
167
|
+
</>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export default DealDetailsFields
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Label } from '@open-mercato/ui/primitives/label'
|
|
5
|
+
|
|
6
|
+
export type DealFormFieldProps = {
|
|
7
|
+
label: string
|
|
8
|
+
/**
|
|
9
|
+
* Stable field key exposed as `data-crud-field-id` and used as the control's `id`.
|
|
10
|
+
* Lets Playwright target the control via the project's `data-crud-field-id` convention
|
|
11
|
+
* (see .ai/lessons.md). Falls back to a generated id for label/control association only.
|
|
12
|
+
*/
|
|
13
|
+
fieldId?: string
|
|
14
|
+
required?: boolean
|
|
15
|
+
hint?: string
|
|
16
|
+
error?: string
|
|
17
|
+
children: React.ReactNode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function DealFormField({ label, fieldId, required, hint, error, children }: DealFormFieldProps) {
|
|
21
|
+
const generatedId = React.useId()
|
|
22
|
+
const controlId = fieldId ?? generatedId
|
|
23
|
+
const control = React.isValidElement(children)
|
|
24
|
+
? React.cloneElement(children as React.ReactElement<{ id?: string }>, { id: controlId })
|
|
25
|
+
: children
|
|
26
|
+
return (
|
|
27
|
+
<div className="space-y-2" data-crud-field-id={fieldId}>
|
|
28
|
+
<Label htmlFor={controlId}>
|
|
29
|
+
{label}
|
|
30
|
+
{required ? <span className="text-destructive"> *</span> : null}
|
|
31
|
+
</Label>
|
|
32
|
+
{control}
|
|
33
|
+
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
|
34
|
+
{error ? <p className="text-xs text-status-error-text">{error}</p> : null}
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default DealFormField
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
5
|
+
|
|
6
|
+
export type DealSectionCardProps = {
|
|
7
|
+
icon: React.ComponentType<{ className?: string }>
|
|
8
|
+
title: string
|
|
9
|
+
subtitle?: React.ReactNode
|
|
10
|
+
actions?: React.ReactNode
|
|
11
|
+
children: React.ReactNode
|
|
12
|
+
className?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function DealSectionCard({
|
|
16
|
+
icon: Icon,
|
|
17
|
+
title,
|
|
18
|
+
subtitle,
|
|
19
|
+
actions,
|
|
20
|
+
children,
|
|
21
|
+
className,
|
|
22
|
+
}: DealSectionCardProps) {
|
|
23
|
+
return (
|
|
24
|
+
<section className={cn('rounded-lg border border-border bg-card shadow-sm p-6 space-y-6', className)}>
|
|
25
|
+
<div className="flex items-center justify-between gap-3">
|
|
26
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
27
|
+
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-brand-violet/10">
|
|
28
|
+
<Icon className="size-4 text-brand-violet" />
|
|
29
|
+
</div>
|
|
30
|
+
<div className="flex min-w-0 flex-col gap-0.5">
|
|
31
|
+
<p className="text-base font-semibold text-foreground">{title}</p>
|
|
32
|
+
{subtitle ? <p className="text-xs text-muted-foreground">{subtitle}</p> : null}
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
|
36
|
+
</div>
|
|
37
|
+
<div className="space-y-4">{children}</div>
|
|
38
|
+
</section>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Wand2 } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
export type DealTipsCardProps = {
|
|
7
|
+
title: string
|
|
8
|
+
tips: string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function DealTipsCard({ title, tips }: DealTipsCardProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div className="rounded-lg border-l-4 border-status-info-border bg-status-info-bg px-5 py-4 space-y-3">
|
|
14
|
+
<div className="flex items-center gap-2">
|
|
15
|
+
<Wand2 className="size-4 text-status-info-icon" />
|
|
16
|
+
<p className="text-sm font-semibold text-status-info-text">{title}</p>
|
|
17
|
+
</div>
|
|
18
|
+
{tips.map((tip, index) => (
|
|
19
|
+
<div key={`${tip}-${index}`} className="flex items-start gap-2">
|
|
20
|
+
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-status-info-icon" />
|
|
21
|
+
<p className="text-xs text-muted-foreground">{tip}</p>
|
|
22
|
+
</div>
|
|
23
|
+
))}
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Flag } from 'lucide-react'
|
|
5
|
+
import {
|
|
6
|
+
Select,
|
|
7
|
+
SelectContent,
|
|
8
|
+
SelectItem,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectTriggerLeading,
|
|
11
|
+
SelectValue,
|
|
12
|
+
} from '@open-mercato/ui/primitives/select'
|
|
13
|
+
import type { PipelineOption } from './useDealPipelines'
|
|
14
|
+
|
|
15
|
+
export type PipelineSelectProps = {
|
|
16
|
+
id?: string
|
|
17
|
+
pipelines: PipelineOption[]
|
|
18
|
+
value?: string | null
|
|
19
|
+
onChange: (id: string) => void
|
|
20
|
+
disabled?: boolean
|
|
21
|
+
placeholder: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function PipelineSelect({
|
|
25
|
+
id,
|
|
26
|
+
pipelines,
|
|
27
|
+
value,
|
|
28
|
+
onChange,
|
|
29
|
+
disabled = false,
|
|
30
|
+
placeholder,
|
|
31
|
+
}: PipelineSelectProps) {
|
|
32
|
+
return (
|
|
33
|
+
<Select
|
|
34
|
+
value={typeof value === 'string' && value ? value : undefined}
|
|
35
|
+
onValueChange={(next) => onChange(next ?? '')}
|
|
36
|
+
disabled={disabled}
|
|
37
|
+
>
|
|
38
|
+
<SelectTrigger id={id} size="default">
|
|
39
|
+
<SelectTriggerLeading>
|
|
40
|
+
<Flag className="size-4 text-muted-foreground" aria-hidden="true" />
|
|
41
|
+
</SelectTriggerLeading>
|
|
42
|
+
<SelectValue placeholder={placeholder} />
|
|
43
|
+
</SelectTrigger>
|
|
44
|
+
<SelectContent>
|
|
45
|
+
{pipelines.map((pipeline) => (
|
|
46
|
+
<SelectItem key={pipeline.id} value={pipeline.id}>
|
|
47
|
+
{pipeline.name}
|
|
48
|
+
</SelectItem>
|
|
49
|
+
))}
|
|
50
|
+
</SelectContent>
|
|
51
|
+
</Select>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default PipelineSelect
|