@controleonline/ui-default 1.0.270 → 1.0.272

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 (89) hide show
  1. package/.github/workflows/integration-source-gate.yml +68 -0
  2. package/.github/workflows/manager-worker.yml +19 -0
  3. package/AGENTS.md +46 -50
  4. package/docs/technical/Contrato-de-colunas.md +67 -0
  5. package/docs/technical/Default-Components.md +76 -0
  6. package/docs/technical/DefaultForm.md +80 -0
  7. package/docs/technical/DefaultInput-e-DefaultSelect.md +73 -0
  8. package/docs/technical/DefaultTable.md +203 -0
  9. package/docs/wiki.md +1 -0
  10. package/package.json +22 -21
  11. package/scripts/validate-wiki-pointer.cjs +13 -0
  12. package/src/react/components/address/DefaultAddress.js +435 -0
  13. package/src/react/components/address/DefaultAddress.styles.js +133 -0
  14. package/src/react/components/address/DefaultAddressMapPane.js +55 -0
  15. package/src/react/components/address/LatLonReadonlyFields.js +51 -0
  16. package/src/react/components/address/defaultAddressHelpers.js +163 -0
  17. package/src/react/components/address/defaultAddressStyles.js +136 -0
  18. package/src/react/components/address/defaultAddressUtils.js +129 -0
  19. package/src/react/components/extra-data/DefaultExtraData.js +152 -0
  20. package/src/react/components/extra-data/DefaultExtraData.styles.js +61 -0
  21. package/src/react/components/files/DefaultFile.js +231 -46
  22. package/src/react/components/files/DefaultFileColumn.js +123 -0
  23. package/src/react/components/files/DefaultFileColumn.styles.js +52 -0
  24. package/src/react/components/files/DefaultFileColumn.utils.js +104 -0
  25. package/src/react/components/filters/CompactFilterSelector.js +51 -35
  26. package/src/react/components/filters/DefaultColumnFilter.js +58 -41
  27. package/src/react/components/filters/DefaultExternalFilters.js +331 -265
  28. package/src/react/components/form/DefaultForm.js +99 -79
  29. package/src/react/components/help/DefaultTooltip.js +59 -33
  30. package/src/react/components/help/DefaultTooltip.styles.js +23 -3
  31. package/src/react/components/inputs/DefaultColorInput.js +153 -0
  32. package/src/react/components/inputs/DefaultFeatherIconPicker.js +146 -0
  33. package/src/react/components/inputs/DefaultFeatherIconPicker.styles.js +113 -0
  34. package/src/react/components/inputs/DefaultInput.js +69 -5
  35. package/src/react/components/inputs/DefaultInput.styles.js +26 -0
  36. package/src/react/components/inputs/DefaultSelect.js +134 -123
  37. package/src/react/components/inputs/defaultInputUtils.js +69 -43
  38. package/src/react/components/map/DefaultMap.native.js +73 -73
  39. package/src/react/components/map/DefaultMap.shared.js +911 -762
  40. package/src/react/components/map/DefaultMap.web.js +124 -68
  41. package/src/react/components/table/DefaultColumnMenu.js +109 -108
  42. package/src/react/components/table/DefaultDebug.js +44 -37
  43. package/src/react/components/table/DefaultSearchModal.js +9 -1
  44. package/src/react/components/table/DefaultTable.grouping.js +98 -0
  45. package/src/react/components/table/DefaultTable.js +213 -107
  46. package/src/react/components/table/DefaultTable.styles.js +367 -314
  47. package/src/react/components/table/DefaultTableBody.js +13 -8
  48. package/src/react/components/table/DefaultTableCards.js +20 -1
  49. package/src/react/components/table/DefaultTableControls.js +180 -205
  50. package/src/react/components/table/DefaultTableImportModal.js +108 -0
  51. package/src/react/components/table/DefaultTableInput.js +1 -0
  52. package/src/react/components/table/DefaultTableRowActions.js +1 -1
  53. package/src/react/components/table/DefaultTableRows.js +224 -363
  54. package/src/react/components/table/DefaultTableSearch.js +28 -5
  55. package/src/react/components/table/DefaultTableToolbar.js +92 -3
  56. package/src/react/components/table/useDefaultTablePagination.js +56 -7
  57. package/src/react/components/table/useDefaultTableStoreSync.js +164 -0
  58. package/src/react/components/upload/DefaultUpload.js +436 -0
  59. package/src/react/components/upload/DefaultUpload.styles.js +153 -0
  60. package/src/react/components/upload/DefaultUploadAttachmentsList.js +138 -0
  61. package/src/react/components/upload/DefaultUploadManagerModal.js +247 -0
  62. package/src/react/components/upload/defaultUploadHelpers.js +108 -0
  63. package/src/react/components/upload/defaultUploadLibrary.js +248 -0
  64. package/src/react/components/upload/fileUpload.js +93 -0
  65. package/src/react/hooks/usePostalCodeLookup.js +145 -0
  66. package/src/react/index.js +6 -0
  67. package/src/react/services/addressFormUtils.js +196 -0
  68. package/src/react/services/addressGeo.js +227 -0
  69. package/src/react/utils/tableVisibleColumnsPreferences.js +29 -12
  70. package/src/tests/react/components/address/defaultAddressHelpers.test.js +104 -0
  71. package/src/tests/react/components/files/DefaultFile.test.js +6 -6
  72. package/src/tests/react/components/form/DefaultForm.test.js +123 -0
  73. package/src/tests/react/components/inputs/DefaultSelect.test.js +108 -79
  74. package/src/tests/react/components/table/DefaultTable.grouping.test.js +49 -0
  75. package/src/tests/react/components/table/DefaultTable.test.js +53 -2
  76. package/src/tests/react/components/table/DefaultTableControls.filters.test.mjs +21 -0
  77. package/src/tests/react/components/table/resolveRowBackgroundColor.test.mjs +45 -0
  78. package/src/tests/react/components/table/useDefaultTableStoreSync.test.js +161 -0
  79. package/src/tests/react/components/upload/DefaultUpload.test.js +353 -0
  80. package/src/tests/react/components/upload/defaultUploadLibrary.test.js +82 -0
  81. package/src/tests/react/components/upload/peopleMediaLibraryPreview.test.mjs +57 -0
  82. package/src/tests/react/hooks/usePostalCodeLookup.test.js +40 -0
  83. package/src/tests/react/services/SMOKE-637-cep-hydrate.md +45 -0
  84. package/src/tests/react/services/addressFormUtils.test.js +176 -0
  85. package/src/tests/react/services/addressGeoLookup.async.test.js +104 -0
  86. package/src/tests/react/services/addressGeoLookup.test.js +105 -0
  87. package/src/tests/react/services/smoke-637/form-cep-12941040.png +0 -0
  88. package/src/tests/react/services/smoke-637/index.html +132 -0
  89. package/src/tests/react/utils/tableVisibleColumnsPreferences.test.js +84 -48
@@ -0,0 +1,203 @@
1
+ # DefaultTable
2
+
3
+ Componente orquestrador de listagens genéricas em React Native / RN Web.
4
+
5
+ **Arquivo:** `src/react/components/table/DefaultTable.js`
6
+ **Composição:** `DefaultTableToolbar` → `DefaultTableBody` → `DefaultTableFooter` (+ botões de add)
7
+
8
+ ## Responsabilidade
9
+
10
+ 1. Resolver **colunas** (store ou prop).
11
+ 2. Em **autoMode** (`data === undefined` e `storeName` preenchido), paginar/ordenar/filtrar via `store.actions`.
12
+ 3. Publicar um objeto **`configs`** no store para filhos lerem sem prop-drilling.
13
+ 4. Persistir preferências (colunas visíveis, filtros, view mode, sort) por escopo **empresa / store / rota**.
14
+
15
+ ## Auto mode vs dados controlados
16
+
17
+ | Modo | Condição | Comportamento |
18
+ | --- | --- | --- |
19
+ | **autoMode** | `data === undefined` e `storeName` não vazio | Pagination hook chama actions do store; `items` / `totalItems` vêm do store |
20
+ | **Controlado** | `data` é array | `items` do store são sobrescritos com `data`; refresh/endReached podem ser callbacks externos |
21
+
22
+ ## Props (principais)
23
+
24
+ | Prop | Tipo / default | Função |
25
+ | --- | --- | --- |
26
+ | `storeName` | string | Nome do store (`useStore`) |
27
+ | `columns` | array | Fallback se `store.getters.columns` vazio |
28
+ | `data` | array \| undefined | Dados controlados; `undefined` ativa autoMode |
29
+ | `actions` | object | Merge sobre `store.actions` |
30
+ | `filters` | object | Filtros iniciais / seed |
31
+ | `requestParams` | object | Params extras em toda request |
32
+ | `pageSize` | number \| null | Tamanho de página |
33
+ | `sort` | `{ field, direction }` \| null | Sort controlado |
34
+ | `onSortChange` | fn | Callback de sort |
35
+ | `hasMore` / `onEndReached` | | Infinite scroll |
36
+ | `isLoading` / `onRefresh` | | Pull-to-refresh |
37
+ | `initialViewMode` | `'table'` \| `'cards'` | Preferência inicial |
38
+ | `forceCardsOnCompact` | true | Cards quando `width <= compactBreakpoint` |
39
+ | `compactBreakpoint` | 768 | Breakpoint de compactação |
40
+ | `showToolbar` / `showSearch` / `showRowActions` | | UI chrome |
41
+ | `showColumnFiltersButton` | true | Botão de filtros de coluna |
42
+ | `add` / `onAdd` / `addLabel` / `addButtonPlacement` | | `'toolbar'` \| `'bottom'` \| `'floating'` |
43
+ | `importAction` / `onImport` / `exportAction` / `onExport` | | Import/export |
44
+ | `onRowPress` / `onEditRow` / `onSaved` | | Interação de linha |
45
+ | `rowActionsComponent` / `rowActionsWidth` / `pinRowActions` | | Ações por linha |
46
+ | `renderCard` / `cardListProps` | | Customização de cards |
47
+ | `summary` / `summaryLabels` | | Rodapé agregado |
48
+ | `footerComponent` | | Slot de footer |
49
+ | `toolbarActions` | array | Ações extras na toolbar |
50
+ | `getOptionsForColumn` | fn | Options locais para selects |
51
+ | `searchKey` | `'search'` | Param de busca textual |
52
+ | `searchPlaceholder` | string | Placeholder |
53
+ | `visibleColumnsPreferenceKey` | string | Chave extra de preferência |
54
+ | `accentColor` / `defaultColor` | | Tema / cor padrão de campos cor |
55
+ | `rowStyle` | | Estilo por linha |
56
+
57
+ ## Contrato do store
58
+
59
+ Esperado em `useStore(storeName)`:
60
+
61
+ | Getter / action | Uso |
62
+ | --- | --- |
63
+ | `getters.columns` | Definição de colunas (preferencial) |
64
+ | `getters.items` | Linhas |
65
+ | `getters.totalItems` | Paginação |
66
+ | `getters.filters` | Filtros ativos |
67
+ | `getters.visibleColumns` | Mapa de colunas visíveis |
68
+ | `getters.configs` | Publicado pelo `DefaultTable` para filhos |
69
+ | `getters.add` | Se `true`, habilita add com `actions.save` ou `onAdd` |
70
+ | `actions.getItems` (ou equivalente na paginação) | Load |
71
+ | `actions.save` | Persistência (form/inline) |
72
+ | `actions.setColumns` / `setItems` / `setFilters` / `setVisibleColumns` / `setConfigs` | Preferidos; senão muta getters |
73
+
74
+ Helpers de publicação interna: `publishStoreValue` / `assignGetterValue`.
75
+
76
+ ### Objeto `configs` (publicação)
77
+
78
+ O table monta `defaultTableConfigs` e grava em `store.getters.configs`, incluindo: view mode efetivo, sort resolvido, callbacks de refresh/endReached, flags de UI, `sortedData`, `debugFallbackParameters`, preferência scope, etc. Filhos (`Toolbar`, `Body`, `Footer`) leem **só** `storeName` e usam `configs`.
79
+
80
+ ## View modes
81
+
82
+ 1. Preferência persistida (`resolveStoredTableViewModePreference`)
83
+ 2. `configs.viewMode` corrente
84
+ 3. Compacto: se `width <= compactBreakpoint` e `forceCardsOnCompact`, força `'cards'`
85
+ 4. Senão `initialViewMode` (default `'table'`)
86
+
87
+ `DefaultTableBody` escolhe `DefaultTableRows` vs `DefaultTableCards`.
88
+
89
+ ## Preferências persistidas (escopo por empresa)
90
+
91
+ Escopo via `resolveDefaultTablePreferenceScope({ companyId, preferenceKey, route, storeName })`.
92
+
93
+ Estrutura no `localStorage` (chave raiz `default-table`):
94
+
95
+ ```text
96
+ default-table[companyKey][storeKey][routeKey] = {
97
+ visibleColumns,
98
+ filters,
99
+ viewMode,
100
+ sort
101
+ }
102
+ ```
103
+
104
+ - `companyKey` = identificador estável da empresa ativa (normalizado).
105
+ - `storeKey` = nome do store (`storeName`).
106
+ - `routeKey` = `preferenceKey` explícito, pathname, `route.name` ou `route.key`.
107
+
108
+ **O que é persistido:** colunas visíveis, filtros de tabela, view mode e sort.
109
+
110
+ **Sanitização:** `sanitizeVisibleColumnsPreference` / `sanitizeTableFiltersPreference` (só campos de colunas filtráveis + `search`; ignora chaves desconhecidas ou bloqueadas com `filter: false`).
111
+
112
+ ### Comportamento na troca de empresa
113
+
114
+ 1. O escopo muda (`companyKey` novo).
115
+ 2. Filtros, colunas, sort e view mode da empresa anterior **não** são reutilizados.
116
+ 3. O table hidrata as preferências salvas para a nova empresa (ou defaults se não houver).
117
+ 4. Listas/opções de filtros (selects de coluna e filtros externos) devem ser **recarregadas** no contexto da empresa ativa — não reutilizar opções carregadas para a empresa anterior.
118
+ 5. Stores e rotas diferentes continuam isolados entre si.
119
+
120
+ **Compatibilidade com formato legado:** preferências antigas no formato `default-table[store][route]` (sem `companyKey`) **não** são copiadas automaticamente para todas as empresas. Escopos sem `companyKey` são ignorados na leitura/gravação (ver testes em `tableVisibleColumnsPreferences.test.js`).
121
+
122
+ Referência de implementação: `src/react/utils/tableVisibleColumnsPreferences.js`.
123
+
124
+ ## Paginação e sort
125
+
126
+ - `useDefaultTablePagination` — monta query (`buildRequestQuery`), página corrente, refresh, end reached
127
+ - `useDefaultTableSortState` / `useDefaultTableSortedData` — sort remoto/local
128
+ - Utils: `resolveDefaultSort`, `isSortableColumn`, `getSortField`, `mergeSortedDataWithLiveItems`
129
+
130
+ Constantes úteis (`DefaultTable.utils.js`):
131
+
132
+ - `DEFAULT_COMPACT_BREAKPOINT = 768`
133
+ - `END_REACHED_THRESHOLD = 0.75`
134
+ - larguras mínimas de célula (identity / money / default)
135
+
136
+ ## Composição interna
137
+
138
+ | Peça | Papel |
139
+ | --- | --- |
140
+ | `DefaultTableToolbar` | Search, controles, import modal, ações |
141
+ | `DefaultTableControls` | Column menu, filters modal, debug |
142
+ | `DefaultTableSearch` / `DefaultSearchModal` | Busca (compacta ou modal) |
143
+ | `DefaultTableRows` | Cabeçalho, filtros por coluna, células, inline edit |
144
+ | `DefaultTableCards` | Layout card + `renderCard` opcional |
145
+ | `DefaultTableInput` | Ponte célula → `DefaultInput` |
146
+ | `DefaultTableFooter` | Totais / summary / paginação visual |
147
+ | `DefaultTableEmptyState` | Empty + `StateStore` |
148
+ | `DefaultTableRowActions` | Slot de ações |
149
+ | `useDefaultTableTheme` | Cores de painel/borda/botão a partir do theme store |
150
+
151
+ ## Fluxo de dados (autoMode)
152
+
153
+ ```mermaid
154
+ sequenceDiagram
155
+ participant T as DefaultTable
156
+ participant P as useDefaultTablePagination
157
+ participant S as Store
158
+ participant API as Backend
159
+ T->>P: buildRequestQuery(page)
160
+ P->>S: actions.getItems(params)
161
+ S->>API: HTTP
162
+ API-->>S: items + totalItems
163
+ S-->>T: getters.items
164
+ T->>T: sortedData + configs
165
+ T->>T: Body/Footer render
166
+ ```
167
+
168
+ ## Integração mínima
169
+
170
+ ```jsx
171
+ <DefaultTable
172
+ storeName="customers"
173
+ onAdd={() => openCreateModal()}
174
+ onRowPress={(row) => openDetails(row)}
175
+ />
176
+ ```
177
+
178
+ Com colunas só via prop (store ainda sem columns):
179
+
180
+ ```jsx
181
+ <DefaultTable
182
+ storeName="customers"
183
+ columns={[
184
+ { name: 'id', label: 'ID', isIdentity: true, editable: false },
185
+ { name: 'name', label: 'Nome', editable: true },
186
+ { name: 'status', label: 'Status', list: 'status/getItems', editable: true },
187
+ ]}
188
+ />
189
+ ```
190
+
191
+ ## Limites e cuidados
192
+
193
+ - Filhos dependem de `configs` no store: não renderize Body isolado sem o orquestrador.
194
+ - Preferências são por **empresa + store + rota**: mudou empresa ou rota, o escopo muda.
195
+ - `stableSerialize` evita loops de `setConfigs`.
196
+ - List stores com escopo de empresa (`categories`, `paymentType`, `wallet`) recebem `company`/`people` automaticamente nos loads de select/coluna.
197
+
198
+ ## Arquivos relacionados
199
+
200
+ - `DefaultTable.js`, `DefaultTable.utils.js`, `DefaultTable.styles.js`
201
+ - Hooks: `useDefaultTablePagination.js`, `useDefaultTableSorting.js`, `useDefaultTableTheme.js`
202
+ - Preferências: `src/react/utils/tableVisibleColumnsPreferences`
203
+ - Testes: `src/tests/react/utils/tableVisibleColumnsPreferences.test.js`
package/docs/wiki.md ADDED
@@ -0,0 +1 @@
1
+ https://github.com/ControleOnline/ui-default/wiki
package/package.json CHANGED
@@ -1,21 +1,22 @@
1
- {
2
- "name": "@controleonline/ui-default",
3
- "version": "1.0.270",
4
- "author": "ControleOnline",
5
- "description": "ControleOnline Default Quasar UI Component",
6
- "license": "MIT",
7
- "main": "src/react/index.js",
8
- "scripts": {},
9
- "repository": {
10
- "type": "git",
11
- "url": "https://github.com/ControleOnline/conline-quasar-ui"
12
- },
13
- "bugs": "",
14
- "homepage": "https://www.controleonline.com/devs/plugins",
15
- "browserslist": [
16
- "last 1 version, not dead, ie >= 11"
17
- ],
18
- "dependencies": {
19
- "@controleonline/ui-common": "*"
20
- }
21
- }
1
+ {
2
+ "name": "@controleonline/ui-default",
3
+ "version": "1.0.272",
4
+ "author": "ControleOnline",
5
+ "description": "ControleOnline Default Quasar UI Component",
6
+ "license": "MIT",
7
+ "main": "src/react/index.js",
8
+ "scripts": {},
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/ControleOnline/conline-quasar-ui"
12
+ },
13
+ "bugs": "",
14
+ "homepage": "https://www.controleonline.com/devs/plugins",
15
+ "browserslist": [
16
+ "last 1 version, not dead, ie >= 11"
17
+ ],
18
+ "dependencies": {
19
+ "@controleonline/ui-common": "*",
20
+ "react-native-vector-icons": "^10.3.0"
21
+ }
22
+ }
@@ -0,0 +1,13 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const root = path.resolve(__dirname, '..');
4
+ const repo = path.basename(root);
5
+ const pointer = fs.readFileSync(path.join(root, 'docs/wiki.md'), 'utf8');
6
+ const expected = `https://github.com/ControleOnline/${repo}/wiki\n`;
7
+ if (pointer !== expected) throw new Error(`docs/wiki.md must contain only ${expected.trim()}`);
8
+ const gitmodulesPath = path.join(root, '.gitmodules');
9
+ if (fs.existsSync(gitmodulesPath)) {
10
+ const gitmodules = fs.readFileSync(gitmodulesPath, 'utf8');
11
+ if (/path\s*=\s*docs\/wiki/.test(gitmodules) || new RegExp(`${repo}\\.wiki\\.git`).test(gitmodules)) throw new Error('docs/wiki must not be configured as a wiki submodule');
12
+ }
13
+ console.log(`Validated wiki pointer for ${repo}.`);
@@ -0,0 +1,435 @@
1
+ import React, {useCallback, useEffect, useMemo, useState} from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Image,
5
+ ScrollView,
6
+ Text,
7
+ TextInput,
8
+ TouchableOpacity,
9
+ View,
10
+ useWindowDimensions,
11
+ } from 'react-native';
12
+ import {
13
+ buildAddressSavePayload,
14
+ listCountries,
15
+ listStates,
16
+ } from '../../services/addressGeo';
17
+ import {
18
+ buildMapMarkerPayload,
19
+ formatCepMask,
20
+ getCurrentCoordinates,
21
+ hasCoordinates,
22
+ hydrateAddressFromRow,
23
+ mergePostalCodeData,
24
+ normalizeCepDigits,
25
+ onlyDigits,
26
+ parseOptionalCoordinate,
27
+ } from '../../services/addressFormUtils';
28
+ import usePostalCodeLookup from '../../hooks/usePostalCodeLookup';
29
+ import DefaultMap from '../map/DefaultMap';
30
+ import LatLonReadonlyFields from './LatLonReadonlyFields';
31
+ import styles from './DefaultAddress.styles';
32
+
33
+ export default function DefaultAddress({
34
+ row = null,
35
+ peopleIri,
36
+ mode = 'create',
37
+ onCancel,
38
+ onSaved,
39
+ saveAction,
40
+ onFormChange = null,
41
+ hideActions = false,
42
+ submitLabel = 'Salvar',
43
+ }) {
44
+ const {width} = useWindowDimensions();
45
+ const isDesktop = width >= 900;
46
+ const [form, setForm] = useState(() => hydrateAddressFromRow(row));
47
+ const [countries, setCountries] = useState([]);
48
+ const [states, setStates] = useState([]);
49
+ const [loadingMeta, setLoadingMeta] = useState(true);
50
+ const [saving, setSaving] = useState(false);
51
+ const [error, setError] = useState(null);
52
+ const [showCountryList, setShowCountryList] = useState(false);
53
+ const [showStateList, setShowStateList] = useState(false);
54
+ // Once the user types lat/lon, keep both fields editable (avoid lock when both become finite).
55
+ const [coordsManualEdit, setCoordsManualEdit] = useState(false);
56
+
57
+ const {
58
+ loadingCep,
59
+ cepError,
60
+ setCepError,
61
+ onCepBlur,
62
+ runLookup,
63
+ cancelPending,
64
+ } = usePostalCodeLookup({
65
+ formCep: form.cep,
66
+ setForm,
67
+ onFormChange,
68
+ setStates,
69
+ enabled: true,
70
+ });
71
+
72
+ useEffect(() => {
73
+ let cancelled = false;
74
+ (async () => {
75
+ setLoadingMeta(true);
76
+ try {
77
+ const [c, s] = await Promise.all([
78
+ listCountries(''),
79
+ listStates(form.countryCode || 'BR'),
80
+ ]);
81
+ if (!cancelled) {
82
+ setCountries(c);
83
+ setStates(s);
84
+ }
85
+ } catch (e) {
86
+ if (!cancelled) setError(e?.message || 'Falha ao carregar país/estado');
87
+ } finally {
88
+ if (!cancelled) setLoadingMeta(false);
89
+ }
90
+ })();
91
+ return () => {
92
+ cancelled = true;
93
+ };
94
+ // eslint-disable-next-line react-hooks/exhaustive-deps
95
+ }, []);
96
+
97
+ useEffect(() => {
98
+ let cancelled = false;
99
+ const initial = hydrateAddressFromRow(row);
100
+ setForm(initial);
101
+ setCoordsManualEdit(false);
102
+ onFormChange?.(initial);
103
+ const digits = onlyDigits(initial.cep);
104
+
105
+ (async () => {
106
+ if (digits.length === 8) {
107
+ try {
108
+ await runLookup(digits, {preserveFilledFields: true});
109
+ } catch {
110
+ // keep editable existing data
111
+ }
112
+ return;
113
+ }
114
+ if (mode !== 'create' || hasCoordinates(initial)) {
115
+ return;
116
+ }
117
+ const coords = await getCurrentCoordinates();
118
+ if (cancelled || !coords) return;
119
+ setForm(prev => {
120
+ const next = {...prev, ...coords};
121
+ onFormChange?.(next);
122
+ return next;
123
+ });
124
+ })();
125
+
126
+ return () => {
127
+ cancelled = true;
128
+ cancelPending();
129
+ };
130
+ }, [mode, row]); // eslint-disable-line react-hooks/exhaustive-deps
131
+
132
+ const onChange = useCallback(
133
+ (key, value) => {
134
+ setForm(prev => {
135
+ let nextValue = value;
136
+ if (key === 'cep') {
137
+ nextValue = normalizeCepDigits(value);
138
+ } else if (key === 'latitude' || key === 'longitude') {
139
+ // Keep raw string while typing so decimals/signs are not truncated mid-edit.
140
+ const parsed = parseOptionalCoordinate(value);
141
+ const trimmed = String(value ?? '').trim();
142
+ const looksComplete =
143
+ trimmed !== '' &&
144
+ trimmed !== '-' &&
145
+ trimmed !== '.' &&
146
+ trimmed !== '-.' &&
147
+ !trimmed.endsWith('.') &&
148
+ !trimmed.endsWith(',');
149
+ nextValue =
150
+ parsed != null && looksComplete ? parsed : value;
151
+ }
152
+ const next = {...prev, [key]: nextValue};
153
+ onFormChange?.(next);
154
+ return next;
155
+ });
156
+ if (key === 'latitude' || key === 'longitude') {
157
+ setCoordsManualEdit(true);
158
+ }
159
+ if (key === 'cep') {
160
+ setCepError(null);
161
+ setError(null);
162
+ setCoordsManualEdit(false);
163
+ }
164
+ },
165
+ [onFormChange, setCepError],
166
+ );
167
+
168
+ const onSelectCountry = useCallback(async item => {
169
+ setForm(prev => ({
170
+ ...prev,
171
+ countryCode: item.code,
172
+ countryName: item.name,
173
+ uf: '',
174
+ stateName: '',
175
+ }));
176
+ setShowCountryList(false);
177
+ try {
178
+ const s = await listStates(item.code);
179
+ setStates(s);
180
+ } catch (e) {
181
+ setError(e?.message || 'Falha ao carregar estados');
182
+ }
183
+ }, []);
184
+
185
+ const onSelectState = useCallback(item => {
186
+ setForm(prev => ({
187
+ ...prev,
188
+ uf: item.uf,
189
+ stateName: item.name,
190
+ }));
191
+ setShowStateList(false);
192
+ }, []);
193
+
194
+ const handleSave = useCallback(async () => {
195
+ if (!peopleIri && mode === 'create') {
196
+ setError('Pessoa/empresa obrigatória');
197
+ return;
198
+ }
199
+ setSaving(true);
200
+ setError(null);
201
+ try {
202
+ const payload = buildAddressSavePayload(form, peopleIri);
203
+ if (mode === 'edit' && row) {
204
+ payload.id = row['@id'] || row.id;
205
+ }
206
+ const saved = await saveAction(payload);
207
+ onSaved?.(saved);
208
+ } catch (e) {
209
+ setError(e?.message || 'Falha ao salvar endereço');
210
+ } finally {
211
+ setSaving(false);
212
+ }
213
+ }, [form, peopleIri, mode, row, saveAction, onSaved]);
214
+
215
+ const countryLabel = useMemo(
216
+ () => form.countryName || form.countryCode || 'Selecione o país',
217
+ [form.countryCode, form.countryName],
218
+ );
219
+ const stateLabel = useMemo(
220
+ () =>
221
+ form.stateName
222
+ ? `${form.stateName} (${form.uf})`
223
+ : form.uf || 'Selecione o estado',
224
+ [form.stateName, form.uf],
225
+ );
226
+
227
+ const mapMarkerPayload = useMemo(() => buildMapMarkerPayload(form), [form]);
228
+ const mapUserCoordinates = hasCoordinates(form)
229
+ ? {latitude: Number(form.latitude), longitude: Number(form.longitude)}
230
+ : null;
231
+
232
+ const displayError = error || cepError;
233
+
234
+ const mapPanel = (
235
+ <View style={[styles.mapPane, isDesktop && styles.mapPaneDesktop]}>
236
+ <Text style={styles.mapPaneTitle}>Mapa</Text>
237
+ {hasCoordinates(form) || mapMarkerPayload ? (
238
+ <View style={[styles.liveMap, isDesktop && styles.liveMapDesktop]}>
239
+ <DefaultMap
240
+ markerPayloads={mapMarkerPayload ? [mapMarkerPayload] : []}
241
+ userCoordinates={mapUserCoordinates}
242
+ />
243
+ </View>
244
+ ) : form.mapStaticUrl ? (
245
+ <Image
246
+ source={{uri: form.mapStaticUrl}}
247
+ style={[styles.mapImage, isDesktop && styles.mapImageDesktop]}
248
+ resizeMode="cover"
249
+ />
250
+ ) : (
251
+ <View style={[styles.mapPlaceholder, isDesktop && styles.mapImageDesktop]}>
252
+ <Text style={styles.mapPlaceholderTitle}>Mapa indisponível</Text>
253
+ <Text style={styles.mapPlaceholderText}>
254
+ Consulte um CEP para carregar a localização quando a API retornar a
255
+ imagem, ou informe latitude/longitude manualmente.
256
+ </Text>
257
+ </View>
258
+ )}
259
+ </View>
260
+ );
261
+
262
+ return (
263
+ <ScrollView
264
+ style={styles.scroll}
265
+ contentContainerStyle={[
266
+ styles.container,
267
+ isDesktop && styles.containerDesktop,
268
+ ]}
269
+ keyboardShouldPersistTaps="handled">
270
+ <View style={[styles.formPane, isDesktop && styles.formPaneDesktop]}>
271
+ {displayError ? <Text style={styles.error}>{displayError}</Text> : null}
272
+ <Text style={styles.hint}>
273
+ Informe o CEP (8 dígitos) para autopreencher via ERP. Número/complemento/apelido são preservados.
274
+ </Text>
275
+
276
+ <Field label="Apelido">
277
+ <TextInput
278
+ style={styles.input}
279
+ value={form.nickname}
280
+ onChangeText={v => onChange('nickname', v)}
281
+ placeholder="Ex.: Casa, Trabalho"
282
+ />
283
+ </Field>
284
+
285
+ <Field label="CEP">
286
+ <View style={styles.row}>
287
+ <TextInput
288
+ testID="address-cep-input"
289
+ style={[styles.input, styles.flex]}
290
+ value={formatCepMask(form.cep)}
291
+ onChangeText={v => onChange('cep', v)}
292
+ onBlur={onCepBlur}
293
+ keyboardType="number-pad"
294
+ maxLength={9}
295
+ placeholder="00000-000"
296
+ />
297
+ {loadingCep ? <ActivityIndicator style={styles.loader} /> : null}
298
+ </View>
299
+ </Field>
300
+
301
+ <Field label="País" style={showCountryList ? styles.fieldRaised : null}>
302
+ <TouchableOpacity
303
+ style={styles.select}
304
+ onPress={() => {
305
+ setShowStateList(false);
306
+ setShowCountryList(open => !open);
307
+ }}
308
+ disabled={loadingMeta}>
309
+ <Text numberOfLines={1}>{countryLabel}</Text>
310
+ </TouchableOpacity>
311
+ {showCountryList ? (
312
+ <ScrollView
313
+ style={styles.dropdown}
314
+ nestedScrollEnabled
315
+ keyboardShouldPersistTaps="handled">
316
+ {countries.map(item => (
317
+ <TouchableOpacity
318
+ key={item.id || item.code}
319
+ style={styles.option}
320
+ onPress={() => onSelectCountry(item)}>
321
+ <Text numberOfLines={1}>
322
+ {item.name} ({item.code})
323
+ </Text>
324
+ </TouchableOpacity>
325
+ ))}
326
+ </ScrollView>
327
+ ) : null}
328
+ </Field>
329
+
330
+ <Field label="Estado" style={showStateList ? styles.fieldRaised : null}>
331
+ <TouchableOpacity
332
+ style={styles.select}
333
+ onPress={() => {
334
+ setShowCountryList(false);
335
+ setShowStateList(open => !open);
336
+ }}
337
+ disabled={loadingMeta}>
338
+ <Text numberOfLines={1}>{stateLabel}</Text>
339
+ </TouchableOpacity>
340
+ {showStateList ? (
341
+ <ScrollView
342
+ style={styles.dropdown}
343
+ nestedScrollEnabled
344
+ keyboardShouldPersistTaps="handled">
345
+ {states.map(item => (
346
+ <TouchableOpacity
347
+ key={item.id || item.uf}
348
+ style={styles.option}
349
+ onPress={() => onSelectState(item)}>
350
+ <Text numberOfLines={1}>
351
+ {item.name} ({item.uf})
352
+ </Text>
353
+ </TouchableOpacity>
354
+ ))}
355
+ </ScrollView>
356
+ ) : null}
357
+ </Field>
358
+
359
+ <Field label="Cidade">
360
+ <TextInput
361
+ testID="address-city-input"
362
+ style={styles.input}
363
+ value={form.city}
364
+ onChangeText={v => onChange('city', v)}
365
+ />
366
+ </Field>
367
+ <Field label="Bairro">
368
+ <TextInput
369
+ testID="address-district-input"
370
+ style={styles.input}
371
+ value={form.district}
372
+ onChangeText={v => onChange('district', v)}
373
+ />
374
+ </Field>
375
+ <Field label="Logradouro">
376
+ <TextInput
377
+ testID="address-street-input"
378
+ style={styles.input}
379
+ value={form.street}
380
+ onChangeText={v => onChange('street', v)}
381
+ />
382
+ </Field>
383
+ <Field label="Número">
384
+ <TextInput
385
+ style={styles.input}
386
+ value={form.number}
387
+ onChangeText={v => onChange('number', v)}
388
+ keyboardType="default"
389
+ />
390
+ </Field>
391
+ <Field label="Complemento">
392
+ <TextInput
393
+ style={styles.input}
394
+ value={form.complement}
395
+ onChangeText={v => onChange('complement', v)}
396
+ />
397
+ </Field>
398
+ <LatLonReadonlyFields
399
+ form={form}
400
+ styles={styles}
401
+ Field={Field}
402
+ editable={!hasCoordinates(form) || coordsManualEdit}
403
+ onChange={onChange}
404
+ />
405
+
406
+ {!hideActions ? (
407
+ <View style={styles.actions}>
408
+ <TouchableOpacity style={styles.btnSecondary} onPress={onCancel}>
409
+ <Text>Cancelar</Text>
410
+ </TouchableOpacity>
411
+ <TouchableOpacity
412
+ style={styles.btnPrimary}
413
+ onPress={handleSave}
414
+ disabled={saving}>
415
+ <Text style={styles.btnPrimaryText}>
416
+ {saving ? 'Salvando...' : submitLabel}
417
+ </Text>
418
+ </TouchableOpacity>
419
+ </View>
420
+ ) : null}
421
+ </View>
422
+
423
+ {mapPanel}
424
+ </ScrollView>
425
+ );
426
+ }
427
+
428
+ function Field({label, children, style = null}) {
429
+ return (
430
+ <View style={[styles.field, style]}>
431
+ <Text style={styles.label}>{label}</Text>
432
+ {children}
433
+ </View>
434
+ );
435
+ }