@controleonline/ui-customers 1.0.81
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/.github/agents/developer.agent.md +30 -0
- package/.github/agents/devops.agent.md +30 -0
- package/.github/agents/qa.agent.md +30 -0
- package/.github/agents/security.agent.md +30 -0
- package/.scrutinizer.yml +20 -0
- package/AGENTS.md +50 -0
- package/FUNDING.yml +1 -0
- package/README.md +23 -0
- package/docs/technical/Client-Details-Franquia-Filial.md +18 -0
- package/docs/technical/Client-Details-People-Name-Case.md +103 -0
- package/docs/technical/Cliente-Vendedor-Vinculo-e-Permissoes.md +152 -0
- package/docs/wiki.md +1 -0
- package/package.json +32 -0
- package/scripts/validate-wiki-pointer.cjs +13 -0
- package/src/react/components/tabs/AddressesTab.js +494 -0
- package/src/react/components/tabs/CategoriesTab.js +196 -0
- package/src/react/components/tabs/ClassificationChips.js +494 -0
- package/src/react/components/tabs/ContactTab.js +621 -0
- package/src/react/components/tabs/ContactTab.styles.js +119 -0
- package/src/react/components/tabs/ContractsTab.js +13 -0
- package/src/react/components/tabs/DocumentsTab.js +463 -0
- package/src/react/components/tabs/DocumentsTab.styles.js +133 -0
- package/src/react/components/tabs/EmployeeContactRow.js +83 -0
- package/src/react/components/tabs/EmployeesTab.js +454 -0
- package/src/react/components/tabs/EmployeesTab.styles.js +172 -0
- package/src/react/components/tabs/FiscalTab.js +6 -0
- package/src/react/components/tabs/FranchiseCommissionSection.js +307 -0
- package/src/react/components/tabs/FranchiseLinksManageModal.js +215 -0
- package/src/react/components/tabs/FranchiseLinksTab.js +282 -0
- package/src/react/components/tabs/GeneralTab.js +466 -0
- package/src/react/components/tabs/GeneralTab.styles.js +101 -0
- package/src/react/components/tabs/MediaTab.js +356 -0
- package/src/react/components/tabs/PeopleCategoriesPanel.js +601 -0
- package/src/react/components/tabs/ProductsTab.js +236 -0
- package/src/react/components/tabs/ProductsTab.styles.js +143 -0
- package/src/react/components/tabs/SalesmanCommissionBlock.js +120 -0
- package/src/react/components/tabs/SalesmanManageModal.js +208 -0
- package/src/react/components/tabs/SalesmanTab.js +436 -0
- package/src/react/components/tabs/SalesmanTab.styles.js +4 -0
- package/src/react/components/tabs/UserApiKeyModal.js +90 -0
- package/src/react/components/tabs/UserFormModal.js +301 -0
- package/src/react/components/tabs/UsersTab.js +426 -0
- package/src/react/components/tabs/UsersTab.styles.js +283 -0
- package/src/react/components/tabs/UsersTab.styles.test.js +42 -0
- package/src/react/components/tabs/contactMutationMessage.js +18 -0
- package/src/react/components/tabs/employeeContacts.js +214 -0
- package/src/react/components/tabs/employeeUnlink.js +68 -0
- package/src/react/components/tabs/employeesTabHelpers.js +227 -0
- package/src/react/components/tabs/franchiseCommissionHelpers.js +134 -0
- package/src/react/components/tabs/franchiseLinksTab.helpers.js +247 -0
- package/src/react/components/tabs/generalTabHelpers.js +120 -0
- package/src/react/components/tabs/salesmanTab.helpers.js +114 -0
- package/src/react/components/tabs/salesmanTabHelpers.js +173 -0
- package/src/react/components/tabs/salesmanTabMedia.js +55 -0
- package/src/react/components/tabs/salesmanTabSession.js +39 -0
- package/src/react/components/tabs/useEmployeeUnlink.js +58 -0
- package/src/react/components/tabs/useFranchiseLinksManage.js +215 -0
- package/src/react/components/tabs/useSalesmanManage.js +222 -0
- package/src/react/components/tabs/usersTabHelpers.js +221 -0
- package/src/react/domain/productSupplierFlow.js +37 -0
- package/src/react/index.js +4 -0
- package/src/react/pages/ClientDetailsSkeleton.js +84 -0
- package/src/react/pages/clientDetailsHelpers.js +17 -0
- package/src/react/pages/clients.js +27 -0
- package/src/react/pages/details.js +529 -0
- package/src/react/pages/details.page.styles.js +105 -0
- package/src/react/pages/details.styles.js +15 -0
- package/src/react/pages/employees.js +69 -0
- package/src/react/pages/franchisees.js +30 -0
- package/src/react/pages/prospects.js +21 -0
- package/src/react/pages/provider.js +21 -0
- package/src/react/router/routes.js +104 -0
- package/src/react/styles/details.js +571 -0
- package/src/store/people_categories/index.js +72 -0
- package/src/tests/browser/manager/client-details-create-user.spec.js +321 -0
- package/src/tests/browser/manager/client-details-employee-unlink.spec.js +215 -0
- package/src/tests/browser/manager/client-details-employees-refresh.spec.js +271 -0
- package/src/tests/browser/manager/client-details-enable.spec.js +241 -0
- package/src/tests/browser/manager/client-details-fiscal-tabs.spec.js +410 -0
- package/src/tests/browser/manager/client-details-franchise-commission.spec.js +318 -0
- package/src/tests/browser/manager/client-details-franchise-links.spec.js +365 -0
- package/src/tests/browser/manager/client-details-media.spec.js +405 -0
- package/src/tests/browser/manager/client-details-relationships.spec.js +313 -0
- package/src/tests/browser/manager/client-details-salesman-commission.spec.js +291 -0
- package/src/tests/browser/manager/client-details-salesman-manage.spec.js +448 -0
- package/src/tests/react/components/tabs/contactMutationMessage.test.js +25 -0
- package/src/tests/react/components/tabs/employeeContacts.test.js +266 -0
- package/src/tests/react/components/tabs/employeeUnlink.test.js +67 -0
- package/src/tests/react/components/tabs/employeesTabHelpers.media.test.js +78 -0
- package/src/tests/react/components/tabs/employeesTabHelpers.test.js +64 -0
- package/src/tests/react/components/tabs/fiscalTabContract.test.mjs +29 -0
- package/src/tests/react/components/tabs/franchiseCommissionHelpers.test.js +128 -0
- package/src/tests/react/components/tabs/franchiseLinksTab.helpers.test.mjs +166 -0
- package/src/tests/react/components/tabs/generalTabHelpers.test.js +130 -0
- package/src/tests/react/components/tabs/peopleMediaTabs.test.js +322 -0
- package/src/tests/react/components/tabs/salesmanTab.helpers.test.mjs +75 -0
- package/src/tests/react/components/tabs/salesmanTabHelpers.test.js +142 -0
- package/src/tests/react/components/tabs/salesmanTabHelpers.test.mjs +92 -0
- package/src/tests/react/components/tabs/usersTabHelpers.test.js +59 -0
- package/src/tests/react/domain/productSupplierFlow.test.mjs +55 -0
- package/src/tests/react/pages/clientDetailsHelpers.test.js +98 -0
- package/src/tests/react/pages/employees.test.js +44 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for Franchise/Filial (PJ→PJ) people_link management.
|
|
3
|
+
* Administrative CRUD is gated by APP_TYPE === MANAGER.
|
|
4
|
+
*
|
|
5
|
+
* task-485: extractEntityId must accept object | IRI string | scalar id so
|
|
6
|
+
* GET /people_links?company=… rows are not filtered out when company/people
|
|
7
|
+
* are not fully expanded in the payload.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Types in people_link.link_type MySQL SET (api-platform-people PeopleLink).
|
|
12
|
+
* Entity SET includes both franchisee and filial — query both.
|
|
13
|
+
* (Older comment about filial not in SET is outdated vs current columnDefinition.)
|
|
14
|
+
*/
|
|
15
|
+
export const FRANCHISE_LINK_TYPES = ['franchisee'];
|
|
16
|
+
|
|
17
|
+
/** Labels / residual payloads. */
|
|
18
|
+
export const FRANCHISE_LINK_TYPES_UI = ['franchisee', 'filial'];
|
|
19
|
+
|
|
20
|
+
export const buildFranchiseLinkReadParams = (companyId, itemsPerPage = 100) => {
|
|
21
|
+
const id = extractEntityId(companyId);
|
|
22
|
+
const company = id || companyId;
|
|
23
|
+
// Scope the response on the server. Boolean enable=true empties the
|
|
24
|
+
// collection on the deployed API; linkType must be an array.
|
|
25
|
+
return {
|
|
26
|
+
company,
|
|
27
|
+
linkType: [...FRANCHISE_LINK_TYPES],
|
|
28
|
+
itemsPerPage,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const extractEntityId = value => {
|
|
33
|
+
if (value == null || value === '') {
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
if (typeof value === 'object') {
|
|
37
|
+
return extractEntityId(value.id || value['@id'] || '');
|
|
38
|
+
}
|
|
39
|
+
return String(value)
|
|
40
|
+
.replace(/\D/g, '')
|
|
41
|
+
.trim();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const toPeopleIri = value => {
|
|
45
|
+
const directIri = String(value?.['@id'] || value || '').trim();
|
|
46
|
+
if (directIri.startsWith('/people/')) {
|
|
47
|
+
return directIri;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const id = extractEntityId(value?.id || value?.['@id'] || value);
|
|
51
|
+
return id ? `/people/${id}` : '';
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const normalizeAppType = value =>
|
|
55
|
+
String(value || '')
|
|
56
|
+
.trim()
|
|
57
|
+
.toUpperCase();
|
|
58
|
+
|
|
59
|
+
/** Administrative link/edit/remove only in MANAGER app type. */
|
|
60
|
+
export const canManageFranchiseLinks = appType =>
|
|
61
|
+
normalizeAppType(appType) === 'MANAGER';
|
|
62
|
+
|
|
63
|
+
export const normalizeFranchiseLinkType = value => {
|
|
64
|
+
const normalized = String(value || '')
|
|
65
|
+
.trim()
|
|
66
|
+
.toLowerCase();
|
|
67
|
+
// Accept UI residual `filial` for display; API read uses FRANCHISE_LINK_TYPES only.
|
|
68
|
+
return FRANCHISE_LINK_TYPES_UI.includes(normalized) ? normalized : '';
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const franchiseLinkTypeLabel = (linkType, t) => {
|
|
72
|
+
const type = normalizeFranchiseLinkType(linkType);
|
|
73
|
+
if (type === 'franchisee') {
|
|
74
|
+
return t?.t?.('people', 'title', 'franchise') || 'Franquia';
|
|
75
|
+
}
|
|
76
|
+
if (type === 'filial') {
|
|
77
|
+
return t?.t?.('people', 'title', 'branch') || 'Filial';
|
|
78
|
+
}
|
|
79
|
+
return type || '-';
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const normalizeFranchiseCandidate = item => {
|
|
83
|
+
// Accept full People object, IRI string or numeric id
|
|
84
|
+
const peopleId = extractEntityId(item);
|
|
85
|
+
const peopleIri = toPeopleIri(item);
|
|
86
|
+
const peopleName = String(
|
|
87
|
+
(typeof item === 'object' && item
|
|
88
|
+
? item.name || item.alias
|
|
89
|
+
: '') || '',
|
|
90
|
+
).trim();
|
|
91
|
+
const peopleAlias = String(
|
|
92
|
+
(typeof item === 'object' && item ? item.alias : '') || '',
|
|
93
|
+
).trim();
|
|
94
|
+
const peopleType = String(
|
|
95
|
+
(typeof item === 'object' && item ? item.peopleType : '') || '',
|
|
96
|
+
).toUpperCase();
|
|
97
|
+
|
|
98
|
+
if (!peopleId || !peopleIri) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Only PJ (legal entity) when type is known; allow missing type (partial embed)
|
|
103
|
+
if (peopleType && peopleType !== 'J') {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
id: peopleId,
|
|
109
|
+
iri: peopleIri,
|
|
110
|
+
name: peopleName || `ID ${peopleId}`,
|
|
111
|
+
alias: peopleAlias,
|
|
112
|
+
peopleType: peopleType || 'J',
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const normalizeFranchiseLink = item => {
|
|
117
|
+
// Linked PJ is in `people` when company is the current client (parent)
|
|
118
|
+
const linked = normalizeFranchiseCandidate(
|
|
119
|
+
item?.people ?? item?.people_id ?? null,
|
|
120
|
+
);
|
|
121
|
+
if (!linked) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const linkType = normalizeFranchiseLinkType(item?.linkType);
|
|
126
|
+
if (!linkType) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
id:
|
|
132
|
+
extractEntityId(item?.id || item?.['@id']) ||
|
|
133
|
+
String(item?.id || item?.['@id'] || ''),
|
|
134
|
+
linkedId: linked.id,
|
|
135
|
+
linkedIri: linked.iri,
|
|
136
|
+
linkedName: linked.name,
|
|
137
|
+
linkedAlias: linked.alias,
|
|
138
|
+
linkType,
|
|
139
|
+
comission: item?.comission ?? item?.commission ?? 0,
|
|
140
|
+
minimumComission:
|
|
141
|
+
item?.minimum_comission ?? item?.minimumComission ?? 0,
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const buildAvailableFranchiseOptions = ({
|
|
146
|
+
candidates,
|
|
147
|
+
linkedItems,
|
|
148
|
+
editingLink,
|
|
149
|
+
}) => {
|
|
150
|
+
const currentLinkedId = extractEntityId(editingLink?.linkedId);
|
|
151
|
+
const linkedIds = new Set(
|
|
152
|
+
(Array.isArray(linkedItems) ? linkedItems : [])
|
|
153
|
+
.map(item => extractEntityId(item?.linkedId))
|
|
154
|
+
.filter(id => id && id !== currentLinkedId),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
return (Array.isArray(candidates) ? candidates : [])
|
|
158
|
+
.map(normalizeFranchiseCandidate)
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
.filter(item => !linkedIds.has(item.id));
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const buildFranchiseSavePayload = ({
|
|
164
|
+
editingLink,
|
|
165
|
+
formData,
|
|
166
|
+
companyIri,
|
|
167
|
+
}) => {
|
|
168
|
+
if (!companyIri || !formData?.linkedIri) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const linkType = normalizeFranchiseLinkType(formData.linkType);
|
|
173
|
+
if (!linkType) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
...(editingLink?.id ? { id: editingLink.id } : {}),
|
|
179
|
+
company: companyIri,
|
|
180
|
+
people: formData.linkedIri,
|
|
181
|
+
linkType,
|
|
182
|
+
comission: Number(String(formData.comission ?? 0).replace(',', '.')) || 0,
|
|
183
|
+
minimum_comission:
|
|
184
|
+
Number(String(formData.minimumComission ?? 0).replace(',', '.')) || 0,
|
|
185
|
+
enable: true,
|
|
186
|
+
};
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export const buildFranchiseLinksFromPeopleLinks = (
|
|
190
|
+
payload,
|
|
191
|
+
{ companyId = '' } = {},
|
|
192
|
+
) => {
|
|
193
|
+
const extractItems = data => {
|
|
194
|
+
if (Array.isArray(data)) return data;
|
|
195
|
+
if (Array.isArray(data?.member)) return data.member;
|
|
196
|
+
if (Array.isArray(data?.['hydra:member'])) return data['hydra:member'];
|
|
197
|
+
if (Array.isArray(data?.items)) return data.items;
|
|
198
|
+
return [];
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const normalizedCompanyId = extractEntityId(companyId);
|
|
202
|
+
|
|
203
|
+
return extractItems(payload)
|
|
204
|
+
.filter(link => {
|
|
205
|
+
// company/people may be object, IRI string or scalar (task-485)
|
|
206
|
+
const linkCompanyId = extractEntityId(
|
|
207
|
+
link?.company ?? link?.company_id,
|
|
208
|
+
);
|
|
209
|
+
const linkType = normalizeFranchiseLinkType(link?.linkType);
|
|
210
|
+
const linkedPeopleId = extractEntityId(
|
|
211
|
+
link?.people ?? link?.people_id,
|
|
212
|
+
);
|
|
213
|
+
const peopleType = String(
|
|
214
|
+
(typeof link?.people === 'object' && link?.people
|
|
215
|
+
? link.people.peopleType
|
|
216
|
+
: '') || '',
|
|
217
|
+
).toUpperCase();
|
|
218
|
+
|
|
219
|
+
if (!linkType || !linkedPeopleId) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
if (normalizedCompanyId && linkCompanyId && linkCompanyId !== normalizedCompanyId) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
// When company filter was requested but row has no company id, keep if people exists
|
|
226
|
+
// (API already filtered by company query param).
|
|
227
|
+
// PJ only when type known
|
|
228
|
+
if (peopleType && peopleType !== 'J') {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
})
|
|
233
|
+
.map(link => {
|
|
234
|
+
const peopleRaw = link?.people;
|
|
235
|
+
let people = peopleRaw;
|
|
236
|
+
if (typeof peopleRaw !== 'object' || peopleRaw == null) {
|
|
237
|
+
const id = extractEntityId(peopleRaw ?? link?.people_id);
|
|
238
|
+
people = id
|
|
239
|
+
? { id, '@id': `/people/${id}`, name: `ID ${id}`, peopleType: 'J' }
|
|
240
|
+
: {};
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
...link,
|
|
244
|
+
people,
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for GeneralTab (registration form, dates, link types).
|
|
3
|
+
* Extracted to keep GeneralTab.js under the absolute 500-line limit.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const normalizeText = value => String(value || '').replace(/\s+/g, ' ').trim();
|
|
7
|
+
const normalizeIdentityValue = value => normalizeText(value);
|
|
8
|
+
const extractId = value => String(value || '').replace(/\D/g, '');
|
|
9
|
+
|
|
10
|
+
const normalizeEnable = value => {
|
|
11
|
+
if (typeof value === 'boolean') {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (typeof value === 'number') {
|
|
16
|
+
return value === 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (typeof value === 'string') {
|
|
20
|
+
const normalized = value.trim().toLowerCase();
|
|
21
|
+
return normalized === '1' || normalized === 'true';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return false;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const formatYmdToBr = value => {
|
|
28
|
+
if (!value) {
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const normalized = String(value).split('T')[0];
|
|
33
|
+
const parts = normalized.split('-');
|
|
34
|
+
if (parts.length !== 3) {
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return `${parts[2]}/${parts[1]}/${parts[0]}`;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const formatDateInput = text => {
|
|
42
|
+
const numbers = String(text || '').replace(/\D/g, '').slice(0, 8);
|
|
43
|
+
if (!numbers) {
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (numbers.length <= 2) {
|
|
48
|
+
return numbers;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (numbers.length <= 4) {
|
|
52
|
+
return `${numbers.slice(0, 2)}/${numbers.slice(2)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return `${numbers.slice(0, 2)}/${numbers.slice(2, 4)}/${numbers.slice(4)}`;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const parseBrDateToYmd = value => {
|
|
59
|
+
const formatted = formatDateInput(value);
|
|
60
|
+
if (formatted.length !== 10) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const [day, month, year] = formatted.split('/').map(v => parseInt(v, 10));
|
|
65
|
+
if (!day || !month || !year) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const candidate = new Date(year, month - 1, day);
|
|
70
|
+
const isValid =
|
|
71
|
+
candidate.getFullYear() === year &&
|
|
72
|
+
candidate.getMonth() === month - 1 &&
|
|
73
|
+
candidate.getDate() === day;
|
|
74
|
+
|
|
75
|
+
if (!isValid) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return `${year.toString().padStart(4, '0')}-${month
|
|
80
|
+
.toString()
|
|
81
|
+
.padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const LINK_TYPE_OPTIONS = [
|
|
85
|
+
{ value: 'employee', translationKey: 'employee' },
|
|
86
|
+
{ value: 'owner', translationKey: 'owner' },
|
|
87
|
+
{ value: 'director', translationKey: 'director' },
|
|
88
|
+
{ value: 'manager', translationKey: 'manager' },
|
|
89
|
+
{ value: 'courier', translationKey: 'courier' },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const normalizeLinkType = value => {
|
|
93
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
94
|
+
return ['employee', 'owner', 'director', 'manager', 'courier'].includes(normalized)
|
|
95
|
+
? normalized
|
|
96
|
+
: 'employee';
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const toPeopleIri = value => {
|
|
100
|
+
const directIri = String(value?.['@id'] || '').trim();
|
|
101
|
+
if (directIri.startsWith('/people/')) {
|
|
102
|
+
return directIri;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const id = String(value?.id || '').replace(/\D/g, '');
|
|
106
|
+
return id ? `/people/${id}` : '';
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export {
|
|
110
|
+
normalizeText,
|
|
111
|
+
normalizeIdentityValue,
|
|
112
|
+
extractId,
|
|
113
|
+
normalizeEnable,
|
|
114
|
+
formatYmdToBr,
|
|
115
|
+
formatDateInput,
|
|
116
|
+
parseBrDateToYmd,
|
|
117
|
+
LINK_TYPE_OPTIONS,
|
|
118
|
+
normalizeLinkType,
|
|
119
|
+
toPeopleIri,
|
|
120
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for SalesmanTab management (MANAGER) and commission labels.
|
|
3
|
+
* Administrative CRUD is gated by APP_TYPE === MANAGER (see AGENTS.md / wiki).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const extractEntityId = value =>
|
|
7
|
+
String(value || '')
|
|
8
|
+
.replace(/\D/g, '')
|
|
9
|
+
.trim();
|
|
10
|
+
|
|
11
|
+
export const toPeopleIri = value => {
|
|
12
|
+
const directIri = String(value?.['@id'] || value || '').trim();
|
|
13
|
+
if (directIri.startsWith('/people/')) {
|
|
14
|
+
return directIri;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const id = extractEntityId(value?.id || value?.['@id'] || value);
|
|
18
|
+
return id ? `/people/${id}` : '';
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const normalizeAppType = value =>
|
|
22
|
+
String(value || '')
|
|
23
|
+
.trim()
|
|
24
|
+
.toUpperCase();
|
|
25
|
+
|
|
26
|
+
/** Administrative link/edit/remove only in MANAGER app type. */
|
|
27
|
+
export const canManageSalesmen = appType => normalizeAppType(appType) === 'MANAGER';
|
|
28
|
+
|
|
29
|
+
export const normalizeCommissionValue = value => {
|
|
30
|
+
const normalized = String(value ?? '')
|
|
31
|
+
.replace(',', '.')
|
|
32
|
+
.trim();
|
|
33
|
+
const parsed = Number.parseFloat(normalized);
|
|
34
|
+
|
|
35
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const formatCommissionLabel = value =>
|
|
39
|
+
`${normalizeCommissionValue(value).toFixed(2).replace('.', ',')}%`;
|
|
40
|
+
|
|
41
|
+
export const normalizeSalesmanCandidate = item => {
|
|
42
|
+
const sellerId = extractEntityId(item?.id || item?.['@id']);
|
|
43
|
+
const sellerIri = toPeopleIri(item);
|
|
44
|
+
const sellerName = String(item?.name || item?.alias || '').trim();
|
|
45
|
+
const sellerAlias = String(item?.alias || '').trim();
|
|
46
|
+
|
|
47
|
+
if (!sellerId || !sellerIri || !sellerName) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
id: sellerId,
|
|
53
|
+
iri: sellerIri,
|
|
54
|
+
name: sellerName,
|
|
55
|
+
alias: sellerAlias,
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const normalizeSalesmanLink = item => {
|
|
60
|
+
const seller = normalizeSalesmanCandidate(item?.company || {});
|
|
61
|
+
if (!seller) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
id: extractEntityId(item?.id || item?.['@id']) || String(item?.id || item?.['@id'] || ''),
|
|
67
|
+
sellerId: seller.id,
|
|
68
|
+
sellerIri: seller.iri,
|
|
69
|
+
sellerName: seller.name,
|
|
70
|
+
sellerAlias: seller.alias,
|
|
71
|
+
commission: normalizeCommissionValue(item?.comission),
|
|
72
|
+
minimumCommission: normalizeCommissionValue(
|
|
73
|
+
item?.minimum_comission ?? item?.minimumComission,
|
|
74
|
+
),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const buildAvailableSalesmanOptions = ({
|
|
79
|
+
salesmen,
|
|
80
|
+
linkedSalesmen,
|
|
81
|
+
editingLink,
|
|
82
|
+
}) => {
|
|
83
|
+
const currentSellerId = extractEntityId(editingLink?.sellerId);
|
|
84
|
+
const linkedSellerIds = new Set(
|
|
85
|
+
(Array.isArray(linkedSalesmen) ? linkedSalesmen : [])
|
|
86
|
+
.map(item => extractEntityId(item?.sellerId))
|
|
87
|
+
.filter(id => id && id !== currentSellerId),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return (Array.isArray(salesmen) ? salesmen : [])
|
|
91
|
+
.map(normalizeSalesmanCandidate)
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.filter(item => !linkedSellerIds.has(item.id));
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const buildSalesmanSavePayload = ({
|
|
97
|
+
editingLink,
|
|
98
|
+
formData,
|
|
99
|
+
clientIri,
|
|
100
|
+
linkType,
|
|
101
|
+
}) => {
|
|
102
|
+
if (!clientIri || !formData?.sellerIri) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
...(editingLink?.id ? { id: editingLink.id } : {}),
|
|
108
|
+
company: formData.sellerIri,
|
|
109
|
+
people: clientIri,
|
|
110
|
+
linkType,
|
|
111
|
+
comission: normalizeCommissionValue(formData.commission),
|
|
112
|
+
minimum_comission: normalizeCommissionValue(formData.minimumCommission),
|
|
113
|
+
};
|
|
114
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for SalesmanTab commission display, override precedence and edit permission.
|
|
3
|
+
*
|
|
4
|
+
* Business rules (see docs/technical/Cliente-Vendedor-Vinculo-e-Permissoes.md):
|
|
5
|
+
* - franchisee link: franchisee pays franchisor
|
|
6
|
+
* - salesman / sellers-client: company pays salesman (salesman receives)
|
|
7
|
+
* - Precedence: client↔salesman commission (if filled) overrides salesman↔company default
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const extractId = value => String(value || '').replace(/\D/g, '');
|
|
11
|
+
|
|
12
|
+
const EDITABLE_COMMISSION_ROLES = ['ROLE_SUPER', 'ROLE_OWNER'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A commission field is "filled" when it is present (including 0).
|
|
16
|
+
* null / undefined / '' are treated as absent (fall through to default).
|
|
17
|
+
*/
|
|
18
|
+
export const isCommissionFilled = value => {
|
|
19
|
+
if (value === null || value === undefined) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
if (typeof value === 'string' && value.trim() === '') {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const toCommissionNumber = value => {
|
|
29
|
+
if (!isCommissionFilled(value)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const numeric = Number(value);
|
|
33
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve effective commission for a client↔salesman link.
|
|
38
|
+
*
|
|
39
|
+
* @param {object|null} clientLink - people_link sellers-client (company=salesman, people=client)
|
|
40
|
+
* @param {object|null} defaultLink - people_link salesman (company=empresa, people=salesman)
|
|
41
|
+
* @returns {{
|
|
42
|
+
* comission: number|null,
|
|
43
|
+
* minimumComission: number|null,
|
|
44
|
+
* isOverride: boolean,
|
|
45
|
+
* source: 'client'|'default'|'none'
|
|
46
|
+
* }}
|
|
47
|
+
*/
|
|
48
|
+
export const resolveEffectiveCommission = (clientLink, defaultLink = null) => {
|
|
49
|
+
const clientComission = toCommissionNumber(clientLink?.comission);
|
|
50
|
+
const clientMinimum = toCommissionNumber(
|
|
51
|
+
clientLink?.minimum_comission ?? clientLink?.minimumComission,
|
|
52
|
+
);
|
|
53
|
+
const defaultComission = toCommissionNumber(defaultLink?.comission);
|
|
54
|
+
const defaultMinimum = toCommissionNumber(
|
|
55
|
+
defaultLink?.minimum_comission ?? defaultLink?.minimumComission,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const clientHasComission = isCommissionFilled(clientLink?.comission);
|
|
59
|
+
const clientHasMinimum = isCommissionFilled(
|
|
60
|
+
clientLink?.minimum_comission ?? clientLink?.minimumComission,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const hasClientOverride = clientHasComission || clientHasMinimum;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
comission: clientHasComission ? clientComission : defaultComission,
|
|
67
|
+
minimumComission: clientHasMinimum ? clientMinimum : defaultMinimum,
|
|
68
|
+
isOverride: hasClientOverride,
|
|
69
|
+
source: hasClientOverride
|
|
70
|
+
? 'client'
|
|
71
|
+
: defaultComission !== null || defaultMinimum !== null
|
|
72
|
+
? 'default'
|
|
73
|
+
: 'none',
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Format a commission percentage for display (pt-BR friendly).
|
|
79
|
+
*/
|
|
80
|
+
export const formatCommissionPercent = value => {
|
|
81
|
+
const numeric = toCommissionNumber(value);
|
|
82
|
+
if (numeric === null) {
|
|
83
|
+
return '—';
|
|
84
|
+
}
|
|
85
|
+
const fixed = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2);
|
|
86
|
+
return `${fixed}%`;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build a short subtitle describing effective commission + override/default.
|
|
91
|
+
*/
|
|
92
|
+
export const formatCommissionSubtitle = effective => {
|
|
93
|
+
if (!effective || effective.source === 'none') {
|
|
94
|
+
return 'Comissão: —';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const main = formatCommissionPercent(effective.comission);
|
|
98
|
+
const min =
|
|
99
|
+
effective.minimumComission !== null
|
|
100
|
+
? ` (mín. ${formatCommissionPercent(effective.minimumComission)})`
|
|
101
|
+
: '';
|
|
102
|
+
const tag = effective.isOverride ? 'override' : 'padrão';
|
|
103
|
+
|
|
104
|
+
return `Comissão: ${main}${min} · ${tag}`;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Edit permission for commission fields on client SalesmanTab.
|
|
109
|
+
* Same criterion as franchisee commission (#10): ROLE_SUPER or ROLE_OWNER.
|
|
110
|
+
*/
|
|
111
|
+
export const canEditSalesmanCommission = (user = null, currentCompany = null) => {
|
|
112
|
+
const roles = Array.isArray(user?.roles)
|
|
113
|
+
? user.roles
|
|
114
|
+
: Array.isArray(user?.role)
|
|
115
|
+
? user.role
|
|
116
|
+
: [];
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
roles.some(role => EDITABLE_COMMISSION_ROLES.includes(String(role))) ||
|
|
120
|
+
Boolean(currentCompany?.user?.owner_enabled)
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Commission fields are only shown in MANAGER (and ADMIN) app types.
|
|
126
|
+
* CRM and other views must not expose commission values.
|
|
127
|
+
*/
|
|
128
|
+
export const shouldDisplayCommission = (appType = '') => {
|
|
129
|
+
const normalized = String(appType || '')
|
|
130
|
+
.trim()
|
|
131
|
+
.toUpperCase();
|
|
132
|
+
return normalized === 'MANAGER' || normalized === 'ADMIN';
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Map of salesman people id → default people_link (linkType=salesman).
|
|
137
|
+
*/
|
|
138
|
+
export const indexDefaultSalesmanLinksByPeopleId = (payload = []) => {
|
|
139
|
+
const items = Array.isArray(payload)
|
|
140
|
+
? payload
|
|
141
|
+
: Array.isArray(payload?.member)
|
|
142
|
+
? payload.member
|
|
143
|
+
: Array.isArray(payload?.['hydra:member'])
|
|
144
|
+
? payload['hydra:member']
|
|
145
|
+
: [];
|
|
146
|
+
|
|
147
|
+
return items.reduce((acc, link) => {
|
|
148
|
+
const peopleId = extractId(link?.people?.id || link?.people?.['@id'] || link?.people);
|
|
149
|
+
if (!peopleId) {
|
|
150
|
+
return acc;
|
|
151
|
+
}
|
|
152
|
+
acc[peopleId] = link;
|
|
153
|
+
return acc;
|
|
154
|
+
}, {});
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Build save payload for people_link commission update.
|
|
159
|
+
*/
|
|
160
|
+
export const buildCommissionSavePayload = (link, {comission, minimumComission}) => {
|
|
161
|
+
const id = extractId(link?.id || link?.['@id']);
|
|
162
|
+
if (!id) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
id,
|
|
168
|
+
comission: toCommissionNumber(comission) ?? 0,
|
|
169
|
+
minimum_comission: toCommissionNumber(minimumComission) ?? 0,
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export {extractId, EDITABLE_COMMISSION_ROLES};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { resolveFileImageUrl } from '@controleonline/ui-common/src/react/utils/fileUrl';
|
|
2
|
+
import { extractId } from './salesmanTabHelpers';
|
|
3
|
+
|
|
4
|
+
const normalizeCollection = payload => {
|
|
5
|
+
if (Array.isArray(payload)) return payload;
|
|
6
|
+
if (!payload || typeof payload !== 'object') return [];
|
|
7
|
+
if (Array.isArray(payload.member)) return payload.member;
|
|
8
|
+
if (Array.isArray(payload['hydra:member'])) return payload['hydra:member'];
|
|
9
|
+
if (Array.isArray(payload.items)) return payload.items;
|
|
10
|
+
return [];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const COMPANY_ICON_MEDIA_TYPES = ['icon'];
|
|
14
|
+
|
|
15
|
+
const fetchPeopleMediaUrl = async ({ peopleActions, peopleId, mediaType }) => {
|
|
16
|
+
const response = await peopleActions.getPeopleMedia({
|
|
17
|
+
people: `/people/${peopleId}`,
|
|
18
|
+
'mediaType.type': mediaType,
|
|
19
|
+
itemsPerPage: 1,
|
|
20
|
+
});
|
|
21
|
+
const media = normalizeCollection(response)[0];
|
|
22
|
+
|
|
23
|
+
return resolveFileImageUrl(media?.file);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const fetchPeopleMediaUrls = async ({ peopleActions, mediaTypes, peopleIds }) => {
|
|
27
|
+
const uniqueIds = [...new Set((peopleIds || []).map(extractId).filter(Boolean))];
|
|
28
|
+
const entries = await Promise.all(
|
|
29
|
+
uniqueIds.map(async peopleId => {
|
|
30
|
+
for (const mediaType of mediaTypes) {
|
|
31
|
+
const imageUrl = await fetchPeopleMediaUrl({
|
|
32
|
+
peopleActions,
|
|
33
|
+
peopleId,
|
|
34
|
+
mediaType,
|
|
35
|
+
}).catch(() => '');
|
|
36
|
+
|
|
37
|
+
if (imageUrl) {
|
|
38
|
+
return [peopleId, imageUrl];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return entries
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.reduce((accumulator, [peopleId, imageUrl]) => {
|
|
49
|
+
accumulator[peopleId] = imageUrl;
|
|
50
|
+
return accumulator;
|
|
51
|
+
}, {});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
export { normalizeCollection, fetchPeopleMediaUrl, fetchPeopleMediaUrls, COMPANY_ICON_MEDIA_TYPES };
|