@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.
- package/.github/workflows/integration-source-gate.yml +68 -0
- package/.github/workflows/manager-worker.yml +19 -0
- package/AGENTS.md +46 -50
- package/docs/technical/Contrato-de-colunas.md +67 -0
- package/docs/technical/Default-Components.md +76 -0
- package/docs/technical/DefaultForm.md +80 -0
- package/docs/technical/DefaultInput-e-DefaultSelect.md +73 -0
- package/docs/technical/DefaultTable.md +203 -0
- package/docs/wiki.md +1 -0
- package/package.json +22 -21
- package/scripts/validate-wiki-pointer.cjs +13 -0
- package/src/react/components/address/DefaultAddress.js +435 -0
- package/src/react/components/address/DefaultAddress.styles.js +133 -0
- package/src/react/components/address/DefaultAddressMapPane.js +55 -0
- package/src/react/components/address/LatLonReadonlyFields.js +51 -0
- package/src/react/components/address/defaultAddressHelpers.js +163 -0
- package/src/react/components/address/defaultAddressStyles.js +136 -0
- package/src/react/components/address/defaultAddressUtils.js +129 -0
- package/src/react/components/extra-data/DefaultExtraData.js +152 -0
- package/src/react/components/extra-data/DefaultExtraData.styles.js +61 -0
- package/src/react/components/files/DefaultFile.js +231 -46
- package/src/react/components/files/DefaultFileColumn.js +123 -0
- package/src/react/components/files/DefaultFileColumn.styles.js +52 -0
- package/src/react/components/files/DefaultFileColumn.utils.js +104 -0
- package/src/react/components/filters/CompactFilterSelector.js +51 -35
- package/src/react/components/filters/DefaultColumnFilter.js +58 -41
- package/src/react/components/filters/DefaultExternalFilters.js +331 -265
- package/src/react/components/form/DefaultForm.js +99 -79
- package/src/react/components/help/DefaultTooltip.js +59 -33
- package/src/react/components/help/DefaultTooltip.styles.js +23 -3
- package/src/react/components/inputs/DefaultColorInput.js +153 -0
- package/src/react/components/inputs/DefaultFeatherIconPicker.js +146 -0
- package/src/react/components/inputs/DefaultFeatherIconPicker.styles.js +113 -0
- package/src/react/components/inputs/DefaultInput.js +69 -5
- package/src/react/components/inputs/DefaultInput.styles.js +26 -0
- package/src/react/components/inputs/DefaultSelect.js +134 -123
- package/src/react/components/inputs/defaultInputUtils.js +69 -43
- package/src/react/components/map/DefaultMap.native.js +73 -73
- package/src/react/components/map/DefaultMap.shared.js +911 -762
- package/src/react/components/map/DefaultMap.web.js +124 -68
- package/src/react/components/table/DefaultColumnMenu.js +109 -108
- package/src/react/components/table/DefaultDebug.js +44 -37
- package/src/react/components/table/DefaultSearchModal.js +9 -1
- package/src/react/components/table/DefaultTable.grouping.js +98 -0
- package/src/react/components/table/DefaultTable.js +213 -107
- package/src/react/components/table/DefaultTable.styles.js +367 -314
- package/src/react/components/table/DefaultTableBody.js +13 -8
- package/src/react/components/table/DefaultTableCards.js +20 -1
- package/src/react/components/table/DefaultTableControls.js +180 -205
- package/src/react/components/table/DefaultTableImportModal.js +108 -0
- package/src/react/components/table/DefaultTableInput.js +1 -0
- package/src/react/components/table/DefaultTableRowActions.js +1 -1
- package/src/react/components/table/DefaultTableRows.js +224 -363
- package/src/react/components/table/DefaultTableSearch.js +28 -5
- package/src/react/components/table/DefaultTableToolbar.js +92 -3
- package/src/react/components/table/useDefaultTablePagination.js +56 -7
- package/src/react/components/table/useDefaultTableStoreSync.js +164 -0
- package/src/react/components/upload/DefaultUpload.js +436 -0
- package/src/react/components/upload/DefaultUpload.styles.js +153 -0
- package/src/react/components/upload/DefaultUploadAttachmentsList.js +138 -0
- package/src/react/components/upload/DefaultUploadManagerModal.js +247 -0
- package/src/react/components/upload/defaultUploadHelpers.js +108 -0
- package/src/react/components/upload/defaultUploadLibrary.js +248 -0
- package/src/react/components/upload/fileUpload.js +93 -0
- package/src/react/hooks/usePostalCodeLookup.js +145 -0
- package/src/react/index.js +6 -0
- package/src/react/services/addressFormUtils.js +196 -0
- package/src/react/services/addressGeo.js +227 -0
- package/src/react/utils/tableVisibleColumnsPreferences.js +29 -12
- package/src/tests/react/components/address/defaultAddressHelpers.test.js +104 -0
- package/src/tests/react/components/files/DefaultFile.test.js +6 -6
- package/src/tests/react/components/form/DefaultForm.test.js +123 -0
- package/src/tests/react/components/inputs/DefaultSelect.test.js +108 -79
- package/src/tests/react/components/table/DefaultTable.grouping.test.js +49 -0
- package/src/tests/react/components/table/DefaultTable.test.js +53 -2
- package/src/tests/react/components/table/DefaultTableControls.filters.test.mjs +21 -0
- package/src/tests/react/components/table/resolveRowBackgroundColor.test.mjs +45 -0
- package/src/tests/react/components/table/useDefaultTableStoreSync.test.js +161 -0
- package/src/tests/react/components/upload/DefaultUpload.test.js +353 -0
- package/src/tests/react/components/upload/defaultUploadLibrary.test.js +82 -0
- package/src/tests/react/components/upload/peopleMediaLibraryPreview.test.mjs +57 -0
- package/src/tests/react/hooks/usePostalCodeLookup.test.js +40 -0
- package/src/tests/react/services/SMOKE-637-cep-hydrate.md +45 -0
- package/src/tests/react/services/addressFormUtils.test.js +176 -0
- package/src/tests/react/services/addressGeoLookup.async.test.js +104 -0
- package/src/tests/react/services/addressGeoLookup.test.js +105 -0
- package/src/tests/react/services/smoke-637/form-cep-12941040.png +0 -0
- package/src/tests/react/services/smoke-637/index.html +132 -0
- 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.
|
|
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
|
+
}
|