@controleonline/ui-customers 1.0.81 → 1.0.83

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 (64) hide show
  1. package/.github/workflows/integration-source-gate.yml +68 -0
  2. package/.github/workflows/publish-npm.yml +91 -0
  3. package/AGENTS.md +12 -6
  4. package/docs/technical/Client-Details-Criar-Usuario.md +113 -0
  5. package/package.json +5 -4
  6. package/src/react/components/tabs/AddressesTab.js +240 -335
  7. package/src/react/components/tabs/DocumentAttachments.js +270 -0
  8. package/src/react/components/tabs/DocumentsTab.js +355 -463
  9. package/src/react/components/tabs/DocumentsTab.styles.js +123 -0
  10. package/src/react/components/tabs/EmployeeCreateFormFields.js +203 -0
  11. package/src/react/components/tabs/EmployeesTab.js +17 -171
  12. package/src/react/components/tabs/EmployeesTab.styles.js +220 -172
  13. package/src/react/components/tabs/GeneralTab.js +84 -46
  14. package/src/react/components/tabs/GeneralTab.styles.js +81 -77
  15. package/src/react/components/tabs/MediaTab.js +50 -5
  16. package/src/react/components/tabs/PeopleCategoriesPanel.js +161 -78
  17. package/src/react/components/tabs/SalesmanTab.js +1 -1
  18. package/src/react/components/tabs/UserFormModal.js +26 -2
  19. package/src/react/components/tabs/UsersTab.js +30 -19
  20. package/src/react/components/tabs/UsersTab.styles.js +257 -257
  21. package/src/react/components/tabs/UsersTabApiKeyModal.js +115 -0
  22. package/src/react/components/tabs/UsersTabUserModal.js +222 -0
  23. package/src/react/components/tabs/addressesTabHelpers.js +190 -0
  24. package/src/react/components/tabs/documentsTabHelpers.js +142 -0
  25. package/src/react/components/tabs/employeeContacts.js +3 -23
  26. package/src/react/components/tabs/employeeUnlink.js +7 -4
  27. package/src/react/components/tabs/employeesTabHelpers.js +33 -9
  28. package/src/react/components/tabs/generalTabHelpers.js +30 -8
  29. package/src/react/components/tabs/useGeneralTabPeopleLink.js +68 -0
  30. package/src/react/components/tabs/usersTabHelpers.js +24 -23
  31. package/src/react/pages/details.page.styles.js +114 -100
  32. package/src/react/pages/employees.js +4 -0
  33. package/src/react/utils/humanCompanyLinkTypes.js +28 -0
  34. package/src/tests/browser/manager/client-details-add-user-modal.spec.js +281 -0
  35. package/src/tests/browser/manager/client-details-address-create-coords.spec.js +414 -0
  36. package/src/tests/browser/manager/client-details-address-edit.spec.js +331 -0
  37. package/src/tests/browser/manager/client-details-contact-edit.spec.js +198 -0
  38. package/src/tests/browser/manager/client-details-create-contact.spec.js +306 -0
  39. package/src/tests/browser/manager/client-details-documents.spec.js +346 -0
  40. package/src/tests/browser/manager/client-details-identity-case.spec.js +227 -0
  41. package/src/tests/browser/manager/client-details-people-type.manifest.md +18 -0
  42. package/src/tests/browser/manager/client-details-people-type.spec.js +345 -0
  43. package/src/tests/browser/manager/client-details-supplier-product-create.spec.js +334 -0
  44. package/src/tests/browser/manager/client-details-users-tab-load.spec.js +194 -0
  45. package/src/tests/browser/manager/employees-salesman-after-sales.spec.js +441 -0
  46. package/src/tests/react/components/SalesmanTab.company.test.js +40 -0
  47. package/src/tests/react/components/tabs/UsersTab.styles.test.js +39 -0
  48. package/src/tests/react/components/tabs/addressesTabHelpers.test.js +78 -0
  49. package/src/tests/react/components/tabs/documentsTabContract.test.mjs +37 -0
  50. package/src/tests/react/components/tabs/documentsTabHelpers.test.mjs +93 -0
  51. package/src/tests/react/components/tabs/employeeContacts.test.js +46 -4
  52. package/src/tests/react/components/tabs/employeeLinkTypes.contract.test.mjs +52 -0
  53. package/src/tests/react/components/tabs/employeeUnlink.test.js +11 -11
  54. package/src/tests/react/components/tabs/employeesTabHelpers.media.test.js +2 -23
  55. package/src/tests/react/components/tabs/employeesTabHelpers.test.js +59 -0
  56. package/src/tests/react/components/tabs/generalTabHelpers.test.js +35 -0
  57. package/src/tests/react/components/tabs/peopleMediaTabs.test.js +217 -217
  58. package/src/tests/react/components/tabs/peopleType.contract.test.mjs +47 -0
  59. package/src/tests/react/components/tabs/resolveSeededLinkType.test.mjs +20 -0
  60. package/src/tests/react/components/tabs/salesmanTabMedia.no-redeclare.test.mjs +33 -0
  61. package/src/tests/react/components/tabs/usersTabHelpers.test.js +20 -0
  62. package/src/tests/react/components/tabs/usersTabHelpers.test.mjs +63 -0
  63. package/src/tests/react/pages/employees.test.js +2 -0
  64. package/src/tests/react/utils/humanCompanyLinkTypes.test.js +50 -0
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Browser smoke — app-community#458
3
+ * Modal "Adicionar Usuário" must show a visible Salvar button (not only Cancelar).
4
+ * Regression: task-428 left primary footer style as text-only → invisible on white modal.
5
+ */
6
+ const {expect, test} = require('playwright/test');
7
+ const {API_ORIGIN} = require('../../../../../../../src/tests/browser/apiOrigin');
8
+ const {version: appVersion} = require('../../../../../../../package.json');
9
+
10
+ const CORS_HEADERS = {
11
+ 'access-control-allow-origin': '*',
12
+ 'access-control-allow-headers':
13
+ 'API-TOKEN, APP-DOMAIN, DEVICE, ACCEPT, CONTENT-TYPE, X-Requested-With',
14
+ 'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
15
+ };
16
+
17
+ const collection = (member = [], summary = {}) => ({
18
+ member,
19
+ 'hydra:member': member,
20
+ totalItems: member.length,
21
+ 'hydra:totalItems': member.length,
22
+ summary,
23
+ });
24
+
25
+ const jsonHeaders = () => ({
26
+ ...CORS_HEADERS,
27
+ 'content-type': 'application/ld+json; charset=utf-8',
28
+ });
29
+
30
+ const textHeaders = () => ({
31
+ ...CORS_HEADERS,
32
+ 'content-type': 'text/css; charset=utf-8',
33
+ });
34
+
35
+ const createCompany = () => ({
36
+ id: 3,
37
+ name: 'Controle Online',
38
+ alias: 'CONTROLE ONLINE',
39
+ panel_enabled: true,
40
+ enabled: true,
41
+ commercial_enabled: true,
42
+ theme: {
43
+ colors: {
44
+ primary: '#0EA5E9',
45
+ secondary: '#F97316',
46
+ },
47
+ },
48
+ configs: {},
49
+ });
50
+
51
+ const createPerson = () => ({
52
+ '@id': '/people/25',
53
+ id: 25,
54
+ name: 'CLAUDIO MOSES FELTRIN MEDEIROS',
55
+ alias: 'CLAUDIO',
56
+ peopleType: 'F',
57
+ enable: true,
58
+ user: [],
59
+ });
60
+
61
+ const TIMEZONES = [
62
+ {'@id': '/timezones/1', id: 1, name: 'America/Sao_Paulo', enabled: true},
63
+ {'@id': '/timezones/2', id: 2, name: 'America/Campo_Grande', enabled: true},
64
+ ];
65
+
66
+ const mockClientDetailsAddUserApi = async page => {
67
+ const company = createCompany();
68
+ const person = createPerson();
69
+ const createdUsers = [];
70
+
71
+ await page.route(`${API_ORIGIN}/**`, async route => {
72
+ const request = route.request();
73
+ const url = new URL(request.url());
74
+ const pathname = url.pathname.replace(/^\/+/, '');
75
+ const method = request.method().toUpperCase();
76
+
77
+ if (method === 'OPTIONS') {
78
+ return route.fulfill({status: 204, headers: CORS_HEADERS, body: ''});
79
+ }
80
+
81
+ if (pathname === 'themes-colors.css') {
82
+ return route.fulfill({
83
+ status: 200,
84
+ headers: textHeaders(),
85
+ body: ':root { --primary: #0ea5e9; --secondary: #f97316; }',
86
+ });
87
+ }
88
+
89
+ if (pathname === 'runtime/ip') {
90
+ return route.fulfill({
91
+ status: 200,
92
+ headers: jsonHeaders(),
93
+ body: JSON.stringify({ip: '127.0.0.1'}),
94
+ });
95
+ }
96
+
97
+ if (pathname === 'people/company/default') {
98
+ return route.fulfill({
99
+ status: 200,
100
+ headers: jsonHeaders(),
101
+ body: JSON.stringify(company),
102
+ });
103
+ }
104
+
105
+ if (pathname === 'people/companies/my') {
106
+ return route.fulfill({
107
+ status: 200,
108
+ headers: jsonHeaders(),
109
+ body: JSON.stringify(collection([company])),
110
+ });
111
+ }
112
+
113
+ if (pathname === 'people/25' || pathname === 'people/25/') {
114
+ return route.fulfill({
115
+ status: 200,
116
+ headers: jsonHeaders(),
117
+ body: JSON.stringify(person),
118
+ });
119
+ }
120
+
121
+ if (pathname === 'timezones' || pathname.startsWith('timezones')) {
122
+ return route.fulfill({
123
+ status: 200,
124
+ headers: jsonHeaders(),
125
+ body: JSON.stringify(collection(TIMEZONES)),
126
+ });
127
+ }
128
+
129
+ if (pathname === 'users' || pathname.startsWith('users')) {
130
+ if (method === 'POST') {
131
+ let body = {};
132
+ try {
133
+ body = request.postDataJSON() || {};
134
+ } catch {
135
+ body = {};
136
+ }
137
+ const created = {
138
+ '@id': `/users/${100 + createdUsers.length}`,
139
+ id: 100 + createdUsers.length,
140
+ username: body.username || body.name || 'newuser',
141
+ role: body.role || 'ROLE_USER',
142
+ timezone: body.timezone || '/timezones/1',
143
+ };
144
+ createdUsers.push(created);
145
+ return route.fulfill({
146
+ status: 201,
147
+ headers: jsonHeaders(),
148
+ body: JSON.stringify(created),
149
+ });
150
+ }
151
+ return route.fulfill({
152
+ status: 200,
153
+ headers: jsonHeaders(),
154
+ body: JSON.stringify(collection(createdUsers)),
155
+ });
156
+ }
157
+
158
+ if (pathname === 'people_links' || pathname.startsWith('people_links')) {
159
+ return route.fulfill({
160
+ status: 200,
161
+ headers: jsonHeaders(),
162
+ body: JSON.stringify(collection([])),
163
+ });
164
+ }
165
+
166
+ if (pathname === 'menus-people') {
167
+ return route.fulfill({
168
+ status: 200,
169
+ headers: jsonHeaders(),
170
+ body: JSON.stringify({modules: {}}),
171
+ });
172
+ }
173
+
174
+ if (pathname === 'configs/discovery-configs') {
175
+ return route.fulfill({
176
+ status: 200,
177
+ headers: jsonHeaders(),
178
+ body: JSON.stringify({configs: {}}),
179
+ });
180
+ }
181
+
182
+ return route.fulfill({
183
+ status: 200,
184
+ headers: jsonHeaders(),
185
+ body: JSON.stringify(collection([])),
186
+ });
187
+ });
188
+
189
+ await page.addInitScript(
190
+ ({appVersion}) => {
191
+ const setLocalStorageItem = (key, value) => {
192
+ try {
193
+ localStorage.setItem(key, value);
194
+ } catch {
195
+ // ignore
196
+ }
197
+ };
198
+
199
+ setLocalStorageItem(
200
+ 'session',
201
+ JSON.stringify({
202
+ id: 7,
203
+ people: '/people/7',
204
+ api_key: 'test-api-key',
205
+ active: 1,
206
+ mycompany: 3,
207
+ roles: ['ROLE_SUPER'],
208
+ }),
209
+ );
210
+ setLocalStorageItem('config', JSON.stringify({language: 'pt-br'}));
211
+ setLocalStorageItem('app-type', 'MANAGER');
212
+ setLocalStorageItem(
213
+ 'device',
214
+ JSON.stringify({
215
+ id: 'web-manager',
216
+ device: 'web-manager',
217
+ type: 'WEB',
218
+ appName: 'Browser Manager',
219
+ appVersion,
220
+ buildNumber: appVersion,
221
+ systemName: 'web',
222
+ systemVersion: 'web',
223
+ deviceType: 'web',
224
+ metadata: {},
225
+ }),
226
+ );
227
+ },
228
+ {appVersion},
229
+ );
230
+
231
+ return {getCreatedUsers: () => createdUsers};
232
+ };
233
+
234
+ test.describe('client-details Adicionar Usuário modal (#458)', () => {
235
+ test('shows visible Salvar button in Adicionar Usuário modal', async ({page}) => {
236
+ await mockClientDetailsAddUserApi(page);
237
+
238
+ await page.goto(
239
+ '/client-details?clientId=25&contextKey=client&initialTab=users',
240
+ );
241
+
242
+ await expect(page.getByText('Usuários', {exact: true}).first()).toBeVisible({
243
+ timeout: 20000,
244
+ });
245
+
246
+ const addBtn = page
247
+ .getByLabel('Adicionar usuário')
248
+ .or(page.getByTestId('users-tab-add-user'))
249
+ .first();
250
+ await expect(addBtn).toBeVisible({timeout: 10000});
251
+ await addBtn.click();
252
+
253
+ await expect(page.getByText('Adicionar Usuário', {exact: true})).toBeVisible({
254
+ timeout: 10000,
255
+ });
256
+
257
+ await expect(page.getByText('Cancelar', {exact: true})).toBeVisible();
258
+
259
+ const salvar = page
260
+ .getByLabel('Salvar')
261
+ .or(page.getByTestId('user-form-save'))
262
+ .or(page.getByText('Salvar', {exact: true}))
263
+ .first();
264
+ await expect(salvar).toBeVisible({timeout: 5000});
265
+
266
+ const box = await salvar.boundingBox();
267
+ expect(box).toBeTruthy();
268
+ expect(box.width).toBeGreaterThan(20);
269
+ expect(box.height).toBeGreaterThan(10);
270
+
271
+ // Timezone list present (must not push footer off-screen)
272
+ await expect(
273
+ page.getByText(/America\/Sao_Paulo|Timezone|timezone/i).first(),
274
+ ).toBeVisible({timeout: 10000}).catch(() => {});
275
+
276
+ await page.getByText('Cancelar', {exact: true}).click();
277
+ await expect(page.getByText('Adicionar Usuário', {exact: true})).toHaveCount(0, {
278
+ timeout: 8000,
279
+ });
280
+ });
281
+ });
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Browser smoke for app-community#283 / ui-customers task-283:
3
+ * Manager → ClientDetails (franchise) → AddressesTab → create address
4
+ * - CEP lookup returns latitude/longitude (Google Maps enrichment path)
5
+ * - optional manual Latitude/Longitude fields are visible and persist on save
6
+ */
7
+ const {expect, test} = require('playwright/test');
8
+ const {API_ORIGIN} = require('../../../../../../../src/tests/browser/apiOrigin');
9
+ const {version: appVersion} = require('../../../../../../../package.json');
10
+
11
+ const CORS_HEADERS = {
12
+ 'access-control-allow-origin': '*',
13
+ 'access-control-allow-headers':
14
+ 'API-TOKEN, APP-DOMAIN, DEVICE, ACCEPT, CONTENT-TYPE, X-Requested-With',
15
+ 'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
16
+ };
17
+
18
+ const collection = (member = [], summary = {}) => ({
19
+ member,
20
+ 'hydra:member': member,
21
+ totalItems: member.length,
22
+ 'hydra:totalItems': member.length,
23
+ summary,
24
+ });
25
+
26
+ const jsonHeaders = () => ({
27
+ ...CORS_HEADERS,
28
+ 'content-type': 'application/ld+json; charset=utf-8',
29
+ });
30
+
31
+ const textHeaders = () => ({
32
+ ...CORS_HEADERS,
33
+ 'content-type': 'text/css; charset=utf-8',
34
+ });
35
+
36
+ const createCompany = () => ({
37
+ id: 3,
38
+ name: 'Controle Online',
39
+ alias: 'CONTROLE ONLINE',
40
+ panel_enabled: true,
41
+ enabled: true,
42
+ commercial_enabled: true,
43
+ theme: {
44
+ colors: {
45
+ primary: '#0EA5E9',
46
+ secondary: '#F97316',
47
+ },
48
+ },
49
+ configs: {},
50
+ });
51
+
52
+ /** CEP response with coords (simulates Postmon/ViaCEP empty + GMaps enrichment). */
53
+ const CEP_WITH_COORDS = {
54
+ cep: '01310100',
55
+ street: 'Avenida Paulista',
56
+ district: 'Bela Vista',
57
+ city: 'São Paulo',
58
+ uf: 'SP',
59
+ state: 'São Paulo',
60
+ country: 'BR',
61
+ countryCode: 'BR',
62
+ latitude: -23.561414,
63
+ longitude: -46.655881,
64
+ provider: 'googlemaps',
65
+ map: {staticUrl: 'https://example.com/static-map'},
66
+ };
67
+
68
+ const mockAddressCreateCoordsApi = async page => {
69
+ const company = createCompany();
70
+ const addressWriteMethods = [];
71
+ const addressWriteBodies = [];
72
+ const postalLookups = [];
73
+ let savedAddresses = [];
74
+
75
+ await page.route(`${API_ORIGIN}/**`, async route => {
76
+ const request = route.request();
77
+ const url = new URL(request.url());
78
+ const pathname = url.pathname.replace(/^\/+/, '');
79
+ const method = request.method().toUpperCase();
80
+
81
+ if (method === 'OPTIONS') {
82
+ return route.fulfill({status: 204, headers: CORS_HEADERS, body: ''});
83
+ }
84
+
85
+ if (pathname === 'themes-colors.css') {
86
+ return route.fulfill({
87
+ status: 200,
88
+ headers: textHeaders(),
89
+ body: ':root { --primary: #0ea5e9; --secondary: #f97316; }',
90
+ });
91
+ }
92
+
93
+ if (pathname === 'runtime/ip') {
94
+ return route.fulfill({
95
+ status: 200,
96
+ headers: jsonHeaders(),
97
+ body: JSON.stringify({ip: '127.0.0.1'}),
98
+ });
99
+ }
100
+
101
+ if (pathname === 'people/company/default') {
102
+ return route.fulfill({
103
+ status: 200,
104
+ headers: jsonHeaders(),
105
+ body: JSON.stringify(company),
106
+ });
107
+ }
108
+
109
+ if (pathname === 'people/companies/my') {
110
+ return route.fulfill({
111
+ status: 200,
112
+ headers: jsonHeaders(),
113
+ body: JSON.stringify(collection([company])),
114
+ });
115
+ }
116
+
117
+ // Franchise / client WITHOUT existing address (create path)
118
+ if (pathname === 'people/50' || pathname === 'people/50/') {
119
+ return route.fulfill({
120
+ status: 200,
121
+ headers: jsonHeaders(),
122
+ body: JSON.stringify({
123
+ id: 50,
124
+ '@id': '/people/50',
125
+ name: 'Franquia Teste LTDA',
126
+ alias: 'FRANQUIA TESTE',
127
+ enabled: true,
128
+ address: savedAddresses,
129
+ people: {id: 50, '@id': '/people/50'},
130
+ }),
131
+ });
132
+ }
133
+
134
+ if (pathname.startsWith('people/') && pathname.includes('/address')) {
135
+ if (method === 'GET') {
136
+ return route.fulfill({
137
+ status: 200,
138
+ headers: jsonHeaders(),
139
+ body: JSON.stringify(collection(savedAddresses)),
140
+ });
141
+ }
142
+ }
143
+
144
+ // postal-codes/{cep} — backend GMaps enrichment path for #283
145
+ if (pathname.startsWith('postal-codes/')) {
146
+ postalLookups.push({pathname, method});
147
+ return route.fulfill({
148
+ status: 200,
149
+ headers: jsonHeaders(),
150
+ body: JSON.stringify(CEP_WITH_COORDS),
151
+ });
152
+ }
153
+
154
+ if (pathname === 'address-geo/countries' || pathname.startsWith('address-geo/')) {
155
+ return route.fulfill({
156
+ status: 200,
157
+ headers: jsonHeaders(),
158
+ body: JSON.stringify(
159
+ collection([
160
+ {id: 1, countrycode: 'BR', countryname: 'Brazil', code: 'BR', name: 'Brazil'},
161
+ ]),
162
+ ),
163
+ });
164
+ }
165
+
166
+ if (pathname === 'addresses' || pathname === 'addresses/' || pathname.startsWith('addresses')) {
167
+ if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
168
+ let body = {};
169
+ try {
170
+ body = request.postDataJSON() || {};
171
+ } catch {
172
+ body = {};
173
+ }
174
+ addressWriteMethods.push(method);
175
+ addressWriteBodies.push(body);
176
+ const id = body.id || 99;
177
+ const saved = {
178
+ id,
179
+ '@id': `/addresses/${id}`,
180
+ street: {
181
+ street: body.street || CEP_WITH_COORDS.street,
182
+ district: {
183
+ district: body.district || CEP_WITH_COORDS.district,
184
+ city: {
185
+ city: body.city || CEP_WITH_COORDS.city,
186
+ state: {
187
+ uf: body.state || body.uf || 'SP',
188
+ country: {countrycode: body.country || 'BR'},
189
+ },
190
+ },
191
+ },
192
+ cep: {cep: body.cep || CEP_WITH_COORDS.cep},
193
+ },
194
+ number: body.number || '',
195
+ complement: body.complement || '',
196
+ nickname: body.nickname || '',
197
+ latitude: body.latitude,
198
+ longitude: body.longitude,
199
+ };
200
+ savedAddresses = [saved];
201
+ return route.fulfill({
202
+ status: 200,
203
+ headers: jsonHeaders(),
204
+ body: JSON.stringify(saved),
205
+ });
206
+ }
207
+ if (method === 'GET') {
208
+ return route.fulfill({
209
+ status: 200,
210
+ headers: jsonHeaders(),
211
+ body: JSON.stringify(collection(savedAddresses)),
212
+ });
213
+ }
214
+ }
215
+
216
+ // Default soft OK for other API calls
217
+ return route.fulfill({
218
+ status: 200,
219
+ headers: jsonHeaders(),
220
+ body: JSON.stringify(collection([])),
221
+ });
222
+ });
223
+
224
+ return {
225
+ getAddressWriteMethods: () => addressWriteMethods.slice(),
226
+ getAddressWriteBodies: () => addressWriteBodies.slice(),
227
+ getPostalLookups: () => postalLookups.slice(),
228
+ };
229
+ };
230
+
231
+ test.describe('franchise address create coords browser smoke (#283)', () => {
232
+ test.beforeEach(async ({page}) => {
233
+ await page.addInitScript(
234
+ ({appVersion}) => {
235
+ const set = (k, v) => {
236
+ try {
237
+ localStorage.setItem(k, v);
238
+ } catch {}
239
+ };
240
+ set(
241
+ 'session',
242
+ JSON.stringify({
243
+ id: 7,
244
+ people: '/people/7',
245
+ api_key: 'test',
246
+ active: 1,
247
+ mycompany: 3,
248
+ roles: ['ROLE_ADMIN'],
249
+ }),
250
+ );
251
+ set('config', JSON.stringify({language: 'pt-br'}));
252
+ set('app-type', 'ERP');
253
+ set(
254
+ 'device',
255
+ JSON.stringify({
256
+ id: 'web',
257
+ device: 'web',
258
+ type: 'WEB',
259
+ appVersion,
260
+ buildNumber: appVersion,
261
+ }),
262
+ );
263
+ },
264
+ {appVersion},
265
+ );
266
+ });
267
+
268
+ test('creates address with CEP coords and optional manual lat/long', async ({page}) => {
269
+ const api = await mockAddressCreateCoordsApi(page);
270
+
271
+ await page.goto('/client-details?clientId=50&contextKey=client');
272
+
273
+ await expect(
274
+ page.getByText(/Franquia Teste|Dados Cadastrais|Endere/i).first(),
275
+ ).toBeVisible({timeout: 20000});
276
+
277
+ const addressesTab = page.getByText(/Endereços|Addresses|endereco/i).first();
278
+ if (await addressesTab.isVisible().catch(() => false)) {
279
+ await addressesTab.click();
280
+ }
281
+
282
+ // Enter edit mode if needed so "add address" is available
283
+ const editModeToggle = page.getByText(/Editar|Edit|Alterar/i).first();
284
+ if (await editModeToggle.isVisible().catch(() => false)) {
285
+ await editModeToggle.click();
286
+ }
287
+
288
+ // Open create address (empty list → add button)
289
+ const addBtn = page
290
+ .getByRole('button', {name: /Adicionar|Novo|Add|Criar/i})
291
+ .or(page.getByText(/Adicionar endere|Novo endere|Add address/i))
292
+ .first();
293
+ if (await addBtn.isVisible({timeout: 8000}).catch(() => false)) {
294
+ await addBtn.click();
295
+ } else {
296
+ // Fallback: plus / add icon in addresses area
297
+ await page
298
+ .locator('[data-testid="address-add"], [class*="iconButton"], button')
299
+ .filter({has: page.locator('svg, [class*="plus"], [name*="plus"]')})
300
+ .first()
301
+ .click({timeout: 5000})
302
+ .catch(() => {});
303
+ }
304
+
305
+ // Form should show optional lat/long fields (DefaultAddress #283)
306
+ const latLabel = page.getByText(/Latitude/i).first();
307
+ const lngLabel = page.getByText(/Longitude/i).first();
308
+ await expect(latLabel).toBeVisible({timeout: 15000});
309
+ await expect(lngLabel).toBeVisible({timeout: 5000});
310
+
311
+ // Fill CEP and blur to trigger postal-codes lookup (GMaps enrichment)
312
+ const cepInput = page
313
+ .getByPlaceholder(/CEP|cep|postal/i)
314
+ .or(page.locator('input[name*="cep"], input[id*="cep"]'))
315
+ .first();
316
+ if (await cepInput.isVisible({timeout: 5000}).catch(() => false)) {
317
+ await cepInput.fill('01310-100');
318
+ await cepInput.blur();
319
+ } else {
320
+ // RN web often uses unlabeled TextInput — fill first numeric-looking field in modal
321
+ const inputs = page.locator('input');
322
+ const count = await inputs.count();
323
+ for (let i = 0; i < count; i++) {
324
+ const input = inputs.nth(i);
325
+ const ph = (await input.getAttribute('placeholder')) || '';
326
+ const kb = (await input.getAttribute('inputmode')) || '';
327
+ if (/cep|postal/i.test(ph) || kb === 'numeric') {
328
+ await input.fill('01310100');
329
+ await input.blur();
330
+ break;
331
+ }
332
+ }
333
+ }
334
+
335
+ // Allow lookup to complete
336
+ await page.waitForTimeout(800);
337
+ await expect.poll(() => api.getPostalLookups().length, {timeout: 10000}).toBeGreaterThan(0);
338
+
339
+ // Manual optional coords: set explicit values (overrides / confirms path)
340
+ const latInput = page
341
+ .locator('input')
342
+ .filter({has: page.locator('xpath=ancestor::*[contains(., "Latitude")]')})
343
+ .first();
344
+ // Fallback fill by visible labels proximity
345
+ const allInputs = page.locator('input');
346
+ const inputCount = await allInputs.count();
347
+ let filledManual = false;
348
+ for (let i = 0; i < inputCount; i++) {
349
+ const input = allInputs.nth(i);
350
+ const val = await input.inputValue().catch(() => '');
351
+ // After CEP merge, lat may already be filled; ensure we can edit
352
+ const nearLat = await input.evaluate(el => {
353
+ const root = el.closest('div,label,section') || el.parentElement;
354
+ return /latitude/i.test((root && root.textContent) || '');
355
+ }).catch(() => false);
356
+ if (nearLat) {
357
+ await input.fill('-23.55');
358
+ filledManual = true;
359
+ break;
360
+ }
361
+ if (val && /^-?\d+\.\d+/.test(val) && Number(val) < 0 && Number(val) > -30) {
362
+ // likely already auto-filled latitude from CEP
363
+ filledManual = true;
364
+ }
365
+ }
366
+ // If structure is flat, still try setting last two decimal inputs
367
+ if (!filledManual && inputCount >= 2) {
368
+ await allInputs.nth(inputCount - 2).fill('-23.55').catch(() => {});
369
+ await allInputs.nth(inputCount - 1).fill('-46.63').catch(() => {});
370
+ }
371
+
372
+ // Street/number if still empty after CEP
373
+ const numberInput = page
374
+ .getByPlaceholder(/número|number|N[úu]mero/i)
375
+ .or(page.locator('input[name*="number"], input[id*="number"]'))
376
+ .first();
377
+ if (await numberInput.isVisible({timeout: 2000}).catch(() => false)) {
378
+ const current = await numberInput.inputValue().catch(() => '');
379
+ if (!current) await numberInput.fill('1578');
380
+ }
381
+
382
+ // Save
383
+ const saveBtn = page.getByRole('button', {name: /Salvar|Save|Gravar/i}).first();
384
+ if (await saveBtn.isVisible({timeout: 5000}).catch(() => false)) {
385
+ await saveBtn.click();
386
+ } else {
387
+ await page.getByText(/Salvar|Save Changes|Gravar/i).first().click({timeout: 5000}).catch(() => {});
388
+ }
389
+
390
+ // Assert create write happened with coordinates
391
+ await expect
392
+ .poll(() => api.getAddressWriteMethods().length, {timeout: 12000})
393
+ .toBeGreaterThan(0);
394
+
395
+ const methods = api.getAddressWriteMethods();
396
+ expect(methods.some(m => m === 'POST' || m === 'PUT' || m === 'PATCH')).toBe(true);
397
+
398
+ const bodies = api.getAddressWriteBodies();
399
+ expect(bodies.length).toBeGreaterThan(0);
400
+ const body = bodies[bodies.length - 1];
401
+ const lat = Number(body.latitude);
402
+ const lng = Number(body.longitude);
403
+ expect(Number.isFinite(lat)).toBe(true);
404
+ expect(Number.isFinite(lng)).toBe(true);
405
+ // Either auto from CEP (Paulista) or manual override
406
+ expect(lat).toBeLessThan(0);
407
+ expect(lng).toBeLessThan(0);
408
+
409
+ // UI should show created address without crash
410
+ await expect(
411
+ page.getByText(/Paulista|Franquia|Endere|1578|-23/i).first(),
412
+ ).toBeVisible({timeout: 10000}).catch(() => {});
413
+ });
414
+ });