@controleonline/ui-customers 1.0.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/agents/developer.agent.md +30 -0
- package/.github/agents/devops.agent.md +30 -0
- package/.github/agents/qa.agent.md +30 -0
- package/.github/agents/security.agent.md +30 -0
- package/.scrutinizer.yml +20 -0
- package/AGENTS.md +50 -0
- package/FUNDING.yml +1 -0
- package/README.md +23 -0
- package/docs/technical/Client-Details-Franquia-Filial.md +18 -0
- package/docs/technical/Client-Details-People-Name-Case.md +103 -0
- package/docs/technical/Cliente-Vendedor-Vinculo-e-Permissoes.md +152 -0
- package/docs/wiki.md +1 -0
- package/package.json +32 -0
- package/scripts/validate-wiki-pointer.cjs +13 -0
- package/src/react/components/tabs/AddressesTab.js +494 -0
- package/src/react/components/tabs/CategoriesTab.js +196 -0
- package/src/react/components/tabs/ClassificationChips.js +494 -0
- package/src/react/components/tabs/ContactTab.js +621 -0
- package/src/react/components/tabs/ContactTab.styles.js +119 -0
- package/src/react/components/tabs/ContractsTab.js +13 -0
- package/src/react/components/tabs/DocumentsTab.js +463 -0
- package/src/react/components/tabs/DocumentsTab.styles.js +133 -0
- package/src/react/components/tabs/EmployeeContactRow.js +83 -0
- package/src/react/components/tabs/EmployeesTab.js +454 -0
- package/src/react/components/tabs/EmployeesTab.styles.js +172 -0
- package/src/react/components/tabs/FiscalTab.js +6 -0
- package/src/react/components/tabs/FranchiseCommissionSection.js +307 -0
- package/src/react/components/tabs/FranchiseLinksManageModal.js +215 -0
- package/src/react/components/tabs/FranchiseLinksTab.js +282 -0
- package/src/react/components/tabs/GeneralTab.js +466 -0
- package/src/react/components/tabs/GeneralTab.styles.js +101 -0
- package/src/react/components/tabs/MediaTab.js +356 -0
- package/src/react/components/tabs/PeopleCategoriesPanel.js +601 -0
- package/src/react/components/tabs/ProductsTab.js +236 -0
- package/src/react/components/tabs/ProductsTab.styles.js +143 -0
- package/src/react/components/tabs/SalesmanCommissionBlock.js +120 -0
- package/src/react/components/tabs/SalesmanManageModal.js +208 -0
- package/src/react/components/tabs/SalesmanTab.js +436 -0
- package/src/react/components/tabs/SalesmanTab.styles.js +4 -0
- package/src/react/components/tabs/UserApiKeyModal.js +90 -0
- package/src/react/components/tabs/UserFormModal.js +301 -0
- package/src/react/components/tabs/UsersTab.js +426 -0
- package/src/react/components/tabs/UsersTab.styles.js +283 -0
- package/src/react/components/tabs/UsersTab.styles.test.js +42 -0
- package/src/react/components/tabs/contactMutationMessage.js +18 -0
- package/src/react/components/tabs/employeeContacts.js +214 -0
- package/src/react/components/tabs/employeeUnlink.js +68 -0
- package/src/react/components/tabs/employeesTabHelpers.js +227 -0
- package/src/react/components/tabs/franchiseCommissionHelpers.js +134 -0
- package/src/react/components/tabs/franchiseLinksTab.helpers.js +247 -0
- package/src/react/components/tabs/generalTabHelpers.js +120 -0
- package/src/react/components/tabs/salesmanTab.helpers.js +114 -0
- package/src/react/components/tabs/salesmanTabHelpers.js +173 -0
- package/src/react/components/tabs/salesmanTabMedia.js +55 -0
- package/src/react/components/tabs/salesmanTabSession.js +39 -0
- package/src/react/components/tabs/useEmployeeUnlink.js +58 -0
- package/src/react/components/tabs/useFranchiseLinksManage.js +215 -0
- package/src/react/components/tabs/useSalesmanManage.js +222 -0
- package/src/react/components/tabs/usersTabHelpers.js +221 -0
- package/src/react/domain/productSupplierFlow.js +37 -0
- package/src/react/index.js +4 -0
- package/src/react/pages/ClientDetailsSkeleton.js +84 -0
- package/src/react/pages/clientDetailsHelpers.js +17 -0
- package/src/react/pages/clients.js +27 -0
- package/src/react/pages/details.js +529 -0
- package/src/react/pages/details.page.styles.js +105 -0
- package/src/react/pages/details.styles.js +15 -0
- package/src/react/pages/employees.js +69 -0
- package/src/react/pages/franchisees.js +30 -0
- package/src/react/pages/prospects.js +21 -0
- package/src/react/pages/provider.js +21 -0
- package/src/react/router/routes.js +104 -0
- package/src/react/styles/details.js +571 -0
- package/src/store/people_categories/index.js +72 -0
- package/src/tests/browser/manager/client-details-create-user.spec.js +321 -0
- package/src/tests/browser/manager/client-details-employee-unlink.spec.js +215 -0
- package/src/tests/browser/manager/client-details-employees-refresh.spec.js +271 -0
- package/src/tests/browser/manager/client-details-enable.spec.js +241 -0
- package/src/tests/browser/manager/client-details-fiscal-tabs.spec.js +410 -0
- package/src/tests/browser/manager/client-details-franchise-commission.spec.js +318 -0
- package/src/tests/browser/manager/client-details-franchise-links.spec.js +365 -0
- package/src/tests/browser/manager/client-details-media.spec.js +405 -0
- package/src/tests/browser/manager/client-details-relationships.spec.js +313 -0
- package/src/tests/browser/manager/client-details-salesman-commission.spec.js +291 -0
- package/src/tests/browser/manager/client-details-salesman-manage.spec.js +448 -0
- package/src/tests/react/components/tabs/contactMutationMessage.test.js +25 -0
- package/src/tests/react/components/tabs/employeeContacts.test.js +266 -0
- package/src/tests/react/components/tabs/employeeUnlink.test.js +67 -0
- package/src/tests/react/components/tabs/employeesTabHelpers.media.test.js +78 -0
- package/src/tests/react/components/tabs/employeesTabHelpers.test.js +64 -0
- package/src/tests/react/components/tabs/fiscalTabContract.test.mjs +29 -0
- package/src/tests/react/components/tabs/franchiseCommissionHelpers.test.js +128 -0
- package/src/tests/react/components/tabs/franchiseLinksTab.helpers.test.mjs +166 -0
- package/src/tests/react/components/tabs/generalTabHelpers.test.js +130 -0
- package/src/tests/react/components/tabs/peopleMediaTabs.test.js +322 -0
- package/src/tests/react/components/tabs/salesmanTab.helpers.test.mjs +75 -0
- package/src/tests/react/components/tabs/salesmanTabHelpers.test.js +142 -0
- package/src/tests/react/components/tabs/salesmanTabHelpers.test.mjs +92 -0
- package/src/tests/react/components/tabs/usersTabHelpers.test.js +59 -0
- package/src/tests/react/domain/productSupplierFlow.test.mjs +55 -0
- package/src/tests/react/pages/clientDetailsHelpers.test.js +98 -0
- package/src/tests/react/pages/employees.test.js +44 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classification on General tab (#377).
|
|
3
|
+
* Shows only associations already linked to the person (chip + X to remove).
|
|
4
|
+
* "+" opens search modal to add from existing categories (can be hundreds).
|
|
5
|
+
* Does NOT create categories — catalog is managed by admin / discoveryCategory.
|
|
6
|
+
*/
|
|
7
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
8
|
+
import {
|
|
9
|
+
ActivityIndicator,
|
|
10
|
+
FlatList,
|
|
11
|
+
Modal,
|
|
12
|
+
StyleSheet,
|
|
13
|
+
Text,
|
|
14
|
+
TextInput,
|
|
15
|
+
TouchableOpacity,
|
|
16
|
+
View,
|
|
17
|
+
} from 'react-native';
|
|
18
|
+
import { useStore } from '@store';
|
|
19
|
+
import { useMessage } from '@controleonline/ui-common/src/react/components/MessageService';
|
|
20
|
+
|
|
21
|
+
const normalizeCollection = payload => {
|
|
22
|
+
if (Array.isArray(payload)) return payload;
|
|
23
|
+
if (!payload || typeof payload !== 'object') return [];
|
|
24
|
+
if (Array.isArray(payload.member)) return payload.member;
|
|
25
|
+
if (Array.isArray(payload['hydra:member'])) return payload['hydra:member'];
|
|
26
|
+
if (Array.isArray(payload.items)) return payload.items;
|
|
27
|
+
return [];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const extractId = value => {
|
|
31
|
+
if (!value && value !== 0) return null;
|
|
32
|
+
if (typeof value === 'number') return value;
|
|
33
|
+
const raw = typeof value === 'string' ? value : value?.id || value?.['@id'];
|
|
34
|
+
if (!raw) return null;
|
|
35
|
+
const match = String(raw).match(/(\d+)$/);
|
|
36
|
+
return match ? parseInt(match[1], 10) : null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const CONTEXT_LABELS = {
|
|
40
|
+
profession: 'Profissão',
|
|
41
|
+
position: 'Cargo',
|
|
42
|
+
sector: 'Setor',
|
|
43
|
+
activity_branch: 'Ramo de atividade',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const contextsForPeopleType = peopleType =>
|
|
47
|
+
String(peopleType || 'J').toUpperCase() === 'F'
|
|
48
|
+
? ['profession', 'position']
|
|
49
|
+
: ['sector', 'activity_branch'];
|
|
50
|
+
|
|
51
|
+
const todayYmd = () => {
|
|
52
|
+
const d = new Date();
|
|
53
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
|
54
|
+
d.getDate(),
|
|
55
|
+
).padStart(2, '0')}`;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const categoryName = row =>
|
|
59
|
+
row?.category?.name || row?.name || `#${extractId(row?.category || row) || '?'}`;
|
|
60
|
+
|
|
61
|
+
const categoryContext = row =>
|
|
62
|
+
row?.category?.context || row?.context || '';
|
|
63
|
+
|
|
64
|
+
const ClassificationChips = ({ client, companyId: companyIdProp = null }) => {
|
|
65
|
+
const peopleStore = useStore('people');
|
|
66
|
+
const categoriesStore = useStore('categories');
|
|
67
|
+
const peopleCategoryStore = useStore('people_category');
|
|
68
|
+
const { showError, showSuccess } = useMessage?.() || {};
|
|
69
|
+
|
|
70
|
+
const clientId = extractId(client?.id || client?.['@id']);
|
|
71
|
+
const peopleType = String(client?.peopleType || 'J').toUpperCase() || 'J';
|
|
72
|
+
const contexts = useMemo(() => contextsForPeopleType(peopleType), [peopleType]);
|
|
73
|
+
|
|
74
|
+
const companyFromStore = extractId(
|
|
75
|
+
peopleStore?.getters?.currentCompany?.id ||
|
|
76
|
+
peopleStore?.getters?.currentCompany?.['@id'] ||
|
|
77
|
+
peopleStore?.getters?.currentCompany,
|
|
78
|
+
);
|
|
79
|
+
const companyId = companyIdProp ? extractId(companyIdProp) : companyFromStore;
|
|
80
|
+
|
|
81
|
+
const [associations, setAssociations] = useState([]);
|
|
82
|
+
const [loading, setLoading] = useState(false);
|
|
83
|
+
const [busyId, setBusyId] = useState(null);
|
|
84
|
+
|
|
85
|
+
// Add modal state
|
|
86
|
+
const [modalOpen, setModalOpen] = useState(false);
|
|
87
|
+
const [modalContext, setModalContext] = useState(null);
|
|
88
|
+
const [search, setSearch] = useState('');
|
|
89
|
+
const [options, setOptions] = useState([]);
|
|
90
|
+
const [optionsLoading, setOptionsLoading] = useState(false);
|
|
91
|
+
const [addingId, setAddingId] = useState(null);
|
|
92
|
+
|
|
93
|
+
const actionsRef = useRef({});
|
|
94
|
+
actionsRef.current = {
|
|
95
|
+
getCategories: categoriesStore?.actions?.getItems,
|
|
96
|
+
getCategory: categoriesStore?.actions?.get,
|
|
97
|
+
getPeopleCategories: peopleCategoryStore?.actions?.getItems,
|
|
98
|
+
savePeopleCategory: peopleCategoryStore?.actions?.save,
|
|
99
|
+
removePeopleCategory: peopleCategoryStore?.actions?.remove,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const loadAssociations = useCallback(async () => {
|
|
103
|
+
if (!clientId) return;
|
|
104
|
+
const getPc = actionsRef.current.getPeopleCategories;
|
|
105
|
+
const getCat = actionsRef.current.getCategories;
|
|
106
|
+
if (!getPc) {
|
|
107
|
+
setAssociations([]);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
setLoading(true);
|
|
111
|
+
try {
|
|
112
|
+
const rows = normalizeCollection(
|
|
113
|
+
await getPc({
|
|
114
|
+
people: `/people/${clientId}`,
|
|
115
|
+
active: true,
|
|
116
|
+
itemsPerPage: 100,
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Hydrate category when API returns only IRI (/categories/123)
|
|
121
|
+
const hydrated = await Promise.all(
|
|
122
|
+
rows.map(async row => {
|
|
123
|
+
const cat = row?.category;
|
|
124
|
+
if (cat && typeof cat === 'object' && cat.name) {
|
|
125
|
+
return row;
|
|
126
|
+
}
|
|
127
|
+
const catId = extractId(cat);
|
|
128
|
+
if (!catId || !getCat) {
|
|
129
|
+
return row;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const getOne = actionsRef.current.getCategory;
|
|
133
|
+
if (getOne) {
|
|
134
|
+
const full = await getOne(catId);
|
|
135
|
+
if (full && (full.name || full.context)) {
|
|
136
|
+
return { ...row, category: full };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const list = normalizeCollection(
|
|
140
|
+
await getCat({ id: catId, itemsPerPage: 1 }),
|
|
141
|
+
);
|
|
142
|
+
const full =
|
|
143
|
+
list.find(c => extractId(c?.id || c?.['@id']) === catId) ||
|
|
144
|
+
list[0];
|
|
145
|
+
if (full) {
|
|
146
|
+
return { ...row, category: full };
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// keep IRI
|
|
150
|
+
}
|
|
151
|
+
return row;
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const allowed = new Set(contexts);
|
|
156
|
+
setAssociations(
|
|
157
|
+
hydrated.filter(r => {
|
|
158
|
+
const ctx = categoryContext(r);
|
|
159
|
+
// If still no context, keep item but it will only show after hydration fails
|
|
160
|
+
// Prefer not dumping into first section: drop if context missing after hydrate
|
|
161
|
+
if (!ctx) return false;
|
|
162
|
+
return allowed.has(ctx);
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
showError?.(e?.message || 'Falha ao carregar classificação');
|
|
167
|
+
setAssociations([]);
|
|
168
|
+
} finally {
|
|
169
|
+
setLoading(false);
|
|
170
|
+
}
|
|
171
|
+
}, [clientId, contexts, showError]);
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
loadAssociations();
|
|
175
|
+
}, [loadAssociations]);
|
|
176
|
+
|
|
177
|
+
const associationsByContext = useMemo(() => {
|
|
178
|
+
const map = {};
|
|
179
|
+
for (const ctx of contexts) map[ctx] = [];
|
|
180
|
+
for (const row of associations) {
|
|
181
|
+
const ctx = categoryContext(row);
|
|
182
|
+
if (ctx && map[ctx]) map[ctx].push(row);
|
|
183
|
+
// do not put unknown context into first section
|
|
184
|
+
}
|
|
185
|
+
return map;
|
|
186
|
+
}, [associations, contexts]);
|
|
187
|
+
|
|
188
|
+
const associatedCategoryIds = useMemo(() => {
|
|
189
|
+
const set = new Set();
|
|
190
|
+
for (const row of associations) {
|
|
191
|
+
const id = extractId(row?.category?.id || row?.category?.['@id'] || row?.category);
|
|
192
|
+
if (id) set.add(id);
|
|
193
|
+
}
|
|
194
|
+
return set;
|
|
195
|
+
}, [associations]);
|
|
196
|
+
|
|
197
|
+
const removeAssociation = async row => {
|
|
198
|
+
const rowId = extractId(row?.id || row?.['@id']);
|
|
199
|
+
if (!rowId) return;
|
|
200
|
+
const remove = actionsRef.current.removePeopleCategory;
|
|
201
|
+
if (!remove) {
|
|
202
|
+
showError?.('Store people_category não disponível. Atualize o app.');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
setBusyId(rowId);
|
|
206
|
+
try {
|
|
207
|
+
await remove(rowId);
|
|
208
|
+
setAssociations(prev => prev.filter(r => extractId(r?.id || r?.['@id']) !== rowId));
|
|
209
|
+
showSuccess?.('Classificação removida');
|
|
210
|
+
} catch (e) {
|
|
211
|
+
showError?.(e?.message || 'Falha ao remover classificação');
|
|
212
|
+
} finally {
|
|
213
|
+
setBusyId(null);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const openAddModal = ctx => {
|
|
218
|
+
setModalContext(ctx);
|
|
219
|
+
setSearch('');
|
|
220
|
+
setOptions([]);
|
|
221
|
+
setModalOpen(true);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const closeModal = () => {
|
|
225
|
+
setModalOpen(false);
|
|
226
|
+
setModalContext(null);
|
|
227
|
+
setSearch('');
|
|
228
|
+
setOptions([]);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Debounced search of existing categories (no create)
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (!modalOpen || !modalContext || !companyId) return;
|
|
234
|
+
|
|
235
|
+
let cancelled = false;
|
|
236
|
+
const timer = setTimeout(async () => {
|
|
237
|
+
const getCategories = actionsRef.current.getCategories;
|
|
238
|
+
if (!getCategories) {
|
|
239
|
+
showError?.('Store categories não disponível.');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
setOptionsLoading(true);
|
|
243
|
+
try {
|
|
244
|
+
const params = {
|
|
245
|
+
context: modalContext,
|
|
246
|
+
company: `/people/${companyId}`,
|
|
247
|
+
itemsPerPage: 30,
|
|
248
|
+
};
|
|
249
|
+
const q = String(search || '').trim();
|
|
250
|
+
if (q) params.name = q;
|
|
251
|
+
|
|
252
|
+
const list = normalizeCollection(await getCategories(params));
|
|
253
|
+
if (cancelled) return;
|
|
254
|
+
// Exclude already associated
|
|
255
|
+
setOptions(
|
|
256
|
+
list.filter(c => {
|
|
257
|
+
const id = extractId(c?.id || c?.['@id']);
|
|
258
|
+
return id && !associatedCategoryIds.has(id);
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
if (!cancelled) {
|
|
263
|
+
setOptions([]);
|
|
264
|
+
showError?.(e?.message || 'Falha ao buscar categorias');
|
|
265
|
+
}
|
|
266
|
+
} finally {
|
|
267
|
+
if (!cancelled) setOptionsLoading(false);
|
|
268
|
+
}
|
|
269
|
+
}, 300);
|
|
270
|
+
|
|
271
|
+
return () => {
|
|
272
|
+
cancelled = true;
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
};
|
|
275
|
+
}, [modalOpen, modalContext, companyId, search, associatedCategoryIds, showError]);
|
|
276
|
+
|
|
277
|
+
const addCategory = async cat => {
|
|
278
|
+
const catId = extractId(cat?.id || cat?.['@id']);
|
|
279
|
+
if (!catId || !clientId) return;
|
|
280
|
+
const save = actionsRef.current.savePeopleCategory;
|
|
281
|
+
if (!save) {
|
|
282
|
+
showError?.('Store people_category não disponível. Atualize o app.');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
setAddingId(catId);
|
|
286
|
+
try {
|
|
287
|
+
const payload = {
|
|
288
|
+
people: `/people/${clientId}`,
|
|
289
|
+
category: `/categories/${catId}`,
|
|
290
|
+
startDate: todayYmd(),
|
|
291
|
+
active: true,
|
|
292
|
+
};
|
|
293
|
+
if (modalContext === 'position' && companyId) {
|
|
294
|
+
payload.peopleCompany = `/people/${companyId}`;
|
|
295
|
+
}
|
|
296
|
+
await save(payload);
|
|
297
|
+
closeModal();
|
|
298
|
+
await loadAssociations();
|
|
299
|
+
showSuccess?.('Classificação adicionada');
|
|
300
|
+
} catch (e) {
|
|
301
|
+
showError?.(e?.message || 'Falha ao adicionar classificação');
|
|
302
|
+
} finally {
|
|
303
|
+
setAddingId(null);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
if (!clientId) return null;
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<View style={styles.wrap}>
|
|
311
|
+
<Text style={styles.title}>Classificação</Text>
|
|
312
|
+
{!companyId ? (
|
|
313
|
+
<Text style={styles.hint}>Selecione uma empresa para gerenciar a classificação.</Text>
|
|
314
|
+
) : loading ? (
|
|
315
|
+
<ActivityIndicator style={{ marginVertical: 8 }} />
|
|
316
|
+
) : (
|
|
317
|
+
contexts.map(ctx => {
|
|
318
|
+
const rows = associationsByContext[ctx] || [];
|
|
319
|
+
return (
|
|
320
|
+
<View key={ctx} style={styles.block}>
|
|
321
|
+
<Text style={styles.section}>{CONTEXT_LABELS[ctx] || ctx}</Text>
|
|
322
|
+
<View style={styles.row}>
|
|
323
|
+
{rows.map(row => {
|
|
324
|
+
const rowId = extractId(row?.id || row?.['@id']);
|
|
325
|
+
const busy = busyId === rowId;
|
|
326
|
+
return (
|
|
327
|
+
<View key={rowId || categoryName(row)} style={[styles.chip, styles.chipOn]}>
|
|
328
|
+
<Text style={[styles.chipText, styles.chipTextOn]} numberOfLines={1}>
|
|
329
|
+
{busy ? '…' : categoryName(row)}
|
|
330
|
+
</Text>
|
|
331
|
+
<TouchableOpacity
|
|
332
|
+
onPress={() => removeAssociation(row)}
|
|
333
|
+
disabled={!!busyId}
|
|
334
|
+
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
335
|
+
accessibilityLabel="Remover classificação"
|
|
336
|
+
style={styles.chipX}
|
|
337
|
+
>
|
|
338
|
+
<Text style={styles.chipXText}>×</Text>
|
|
339
|
+
</TouchableOpacity>
|
|
340
|
+
</View>
|
|
341
|
+
);
|
|
342
|
+
})}
|
|
343
|
+
<TouchableOpacity
|
|
344
|
+
onPress={() => openAddModal(ctx)}
|
|
345
|
+
style={[styles.chip, styles.chipAdd]}
|
|
346
|
+
accessibilityLabel={`Adicionar ${CONTEXT_LABELS[ctx] || ctx}`}
|
|
347
|
+
>
|
|
348
|
+
<Text style={styles.chipAddText}>+</Text>
|
|
349
|
+
</TouchableOpacity>
|
|
350
|
+
</View>
|
|
351
|
+
{rows.length === 0 && (
|
|
352
|
+
<Text style={styles.hintEmpty}>Nenhuma classificação. Use + para adicionar.</Text>
|
|
353
|
+
)}
|
|
354
|
+
</View>
|
|
355
|
+
);
|
|
356
|
+
})
|
|
357
|
+
)}
|
|
358
|
+
|
|
359
|
+
<Modal visible={modalOpen} transparent animationType="fade" onRequestClose={closeModal}>
|
|
360
|
+
<View style={styles.modalOverlay}>
|
|
361
|
+
<View style={styles.modalBox}>
|
|
362
|
+
<View style={styles.modalHeader}>
|
|
363
|
+
<Text style={styles.modalTitle}>
|
|
364
|
+
Adicionar {CONTEXT_LABELS[modalContext] || 'categoria'}
|
|
365
|
+
</Text>
|
|
366
|
+
<TouchableOpacity onPress={closeModal} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
|
367
|
+
<Text style={styles.modalClose}>×</Text>
|
|
368
|
+
</TouchableOpacity>
|
|
369
|
+
</View>
|
|
370
|
+
<TextInput
|
|
371
|
+
value={search}
|
|
372
|
+
onChangeText={setSearch}
|
|
373
|
+
placeholder="Buscar categoria…"
|
|
374
|
+
placeholderTextColor="#94A3B8"
|
|
375
|
+
style={styles.searchInput}
|
|
376
|
+
autoFocus
|
|
377
|
+
/>
|
|
378
|
+
{optionsLoading ? (
|
|
379
|
+
<ActivityIndicator style={{ marginVertical: 16 }} />
|
|
380
|
+
) : (
|
|
381
|
+
<FlatList
|
|
382
|
+
data={options}
|
|
383
|
+
keyExtractor={item => String(extractId(item?.id || item?.['@id']) || item?.name)}
|
|
384
|
+
keyboardShouldPersistTaps="handled"
|
|
385
|
+
style={styles.optionsList}
|
|
386
|
+
ListEmptyComponent={
|
|
387
|
+
<Text style={styles.hint}>
|
|
388
|
+
{search.trim()
|
|
389
|
+
? 'Nenhuma categoria encontrada.'
|
|
390
|
+
: 'Digite para buscar ou aguarde a lista.'}
|
|
391
|
+
</Text>
|
|
392
|
+
}
|
|
393
|
+
renderItem={({ item }) => {
|
|
394
|
+
const id = extractId(item?.id || item?.['@id']);
|
|
395
|
+
const busy = addingId === id;
|
|
396
|
+
return (
|
|
397
|
+
<TouchableOpacity
|
|
398
|
+
style={styles.optionRow}
|
|
399
|
+
onPress={() => addCategory(item)}
|
|
400
|
+
disabled={!!addingId}
|
|
401
|
+
>
|
|
402
|
+
<Text style={styles.optionText}>{busy ? '…' : item?.name}</Text>
|
|
403
|
+
</TouchableOpacity>
|
|
404
|
+
);
|
|
405
|
+
}}
|
|
406
|
+
/>
|
|
407
|
+
)}
|
|
408
|
+
</View>
|
|
409
|
+
</View>
|
|
410
|
+
</Modal>
|
|
411
|
+
</View>
|
|
412
|
+
);
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const styles = StyleSheet.create({
|
|
416
|
+
wrap: { marginTop: 16, marginBottom: 8 },
|
|
417
|
+
title: { fontSize: 15, fontWeight: '600', color: '#0F172A', marginBottom: 8 },
|
|
418
|
+
block: { marginBottom: 12 },
|
|
419
|
+
section: {
|
|
420
|
+
fontSize: 12,
|
|
421
|
+
fontWeight: '600',
|
|
422
|
+
color: '#64748B',
|
|
423
|
+
textTransform: 'uppercase',
|
|
424
|
+
letterSpacing: 0.4,
|
|
425
|
+
marginBottom: 8,
|
|
426
|
+
},
|
|
427
|
+
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, alignItems: 'center' },
|
|
428
|
+
chip: {
|
|
429
|
+
flexDirection: 'row',
|
|
430
|
+
alignItems: 'center',
|
|
431
|
+
borderRadius: 16,
|
|
432
|
+
paddingLeft: 12,
|
|
433
|
+
paddingRight: 6,
|
|
434
|
+
paddingVertical: 6,
|
|
435
|
+
borderWidth: 1,
|
|
436
|
+
maxWidth: '100%',
|
|
437
|
+
},
|
|
438
|
+
chipOn: { backgroundColor: '#DBEAFE', borderColor: '#3B82F6' },
|
|
439
|
+
chipAdd: {
|
|
440
|
+
backgroundColor: '#F8FAFC',
|
|
441
|
+
borderColor: '#94A3B8',
|
|
442
|
+
borderStyle: 'dashed',
|
|
443
|
+
paddingHorizontal: 12,
|
|
444
|
+
paddingLeft: 12,
|
|
445
|
+
paddingRight: 12,
|
|
446
|
+
},
|
|
447
|
+
chipText: { fontSize: 13, maxWidth: 180 },
|
|
448
|
+
chipTextOn: { color: '#1E40AF', fontWeight: '600' },
|
|
449
|
+
chipX: { marginLeft: 4, paddingHorizontal: 4 },
|
|
450
|
+
chipXText: { fontSize: 18, lineHeight: 18, color: '#1E40AF', fontWeight: '600' },
|
|
451
|
+
chipAddText: { fontSize: 18, lineHeight: 20, color: '#475569', fontWeight: '600' },
|
|
452
|
+
hint: { fontSize: 12, color: '#94A3B8', paddingVertical: 8 },
|
|
453
|
+
hintEmpty: { fontSize: 11, color: '#94A3B8', marginTop: 4 },
|
|
454
|
+
modalOverlay: {
|
|
455
|
+
flex: 1,
|
|
456
|
+
backgroundColor: 'rgba(15, 23, 42, 0.45)',
|
|
457
|
+
justifyContent: 'center',
|
|
458
|
+
padding: 24,
|
|
459
|
+
},
|
|
460
|
+
modalBox: {
|
|
461
|
+
backgroundColor: '#fff',
|
|
462
|
+
borderRadius: 12,
|
|
463
|
+
maxHeight: '70%',
|
|
464
|
+
padding: 16,
|
|
465
|
+
},
|
|
466
|
+
modalHeader: {
|
|
467
|
+
flexDirection: 'row',
|
|
468
|
+
alignItems: 'center',
|
|
469
|
+
justifyContent: 'space-between',
|
|
470
|
+
marginBottom: 12,
|
|
471
|
+
},
|
|
472
|
+
modalTitle: { fontSize: 16, fontWeight: '600', color: '#0F172A', flex: 1 },
|
|
473
|
+
modalClose: { fontSize: 28, lineHeight: 28, color: '#64748B', paddingLeft: 12 },
|
|
474
|
+
searchInput: {
|
|
475
|
+
borderWidth: 1,
|
|
476
|
+
borderColor: '#CBD5E1',
|
|
477
|
+
borderRadius: 8,
|
|
478
|
+
paddingHorizontal: 12,
|
|
479
|
+
paddingVertical: 10,
|
|
480
|
+
fontSize: 14,
|
|
481
|
+
color: '#0F172A',
|
|
482
|
+
marginBottom: 8,
|
|
483
|
+
},
|
|
484
|
+
optionsList: { maxHeight: 320 },
|
|
485
|
+
optionRow: {
|
|
486
|
+
paddingVertical: 12,
|
|
487
|
+
paddingHorizontal: 4,
|
|
488
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
489
|
+
borderBottomColor: '#E2E8F0',
|
|
490
|
+
},
|
|
491
|
+
optionText: { fontSize: 14, color: '#0F172A' },
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
export default ClassificationChips;
|