@bit.rhplus/ui2.module-dropdown-list 0.1.111 → 0.1.112

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/index.jsx CHANGED
@@ -1,395 +1,653 @@
1
- /* eslint-disable */
2
- import React, { useEffect, useRef, useCallback } from 'react';
3
- import Input from 'antd/es/input';
4
- import Select from 'antd/es/select';
5
- import AutoComplete from 'antd/es/auto-complete';
6
- import Button from 'antd/es/button';
7
- import { SearchOutlined } from '@ant-design/icons';
8
- import AgGrid from '@bit.rhplus/ag-grid';
9
- import { useAtom } from 'jotai';
10
- import DraggableModal from '@bit.rhplus/draggable-modal';
11
- import { recentItemsAtomFamily } from './store/recentItemsStore';
12
- import { useModuleDropdownList } from './useModuleDropdownList';
13
- import './ModuleInput.css';
14
-
15
- const { Option } = Select;
16
-
17
- const ModuleDropdownList = (props) => {
18
- const {
19
- value,
20
- onChange,
21
- placeholder = 'Vyberte',
22
- disabled = false,
23
- readOnly = false,
24
- style = {},
25
- width,
26
- maxRecentItems = 5,
27
- moduleDefinition,
28
- displayMode = 'recent',
29
- getContainer = () => document.body, // Vlastnost pro nastavení kontejneru modálu
30
- // Free-text mód
31
- allowFreeText = false,
32
- onFreeTextCommit, // (text: string) => void — voláno při Enter nebo blur bez výběru
33
- onSearchChange, // (text: string) => void — sleduje aktuální text v inputu
34
- initialSearchValue = '', // Počáteční text pro předvyplnění search inputu
35
- } = props;
36
-
37
- // Interní stavy pro Select
38
- const containerRef = useRef(null);
39
-
40
- // Free-text mód — text který uživatel napsal do search inputu
41
- const [freeTextInput, setFreeTextInput] = React.useState(initialSearchValue || '');
42
-
43
- // Zabrání dvojímu spuštění onFreeTextCommit při výběru z AutoComplete dropdown
44
- const justSelectedRef = useRef(false);
45
-
46
- const processedStyle =
47
- typeof style === 'string' ? { width: style } : { ...style };
48
-
49
- // Priorita nastavení šířky: 1. prop width, 2. style.width, 3. defaultní 100%
50
- if (width) {
51
- processedStyle.width = width;
52
- } else if (!processedStyle.width) {
53
- processedStyle.width = '100%';
54
- }
55
-
56
- // Použití Jotai atom pro poslední vybrané položky daného typu modulu
57
- const [recentItems, setRecentItems] = useAtom(
58
- recentItemsAtomFamily(moduleDefinition?.moduleName || '')
59
- );
60
-
61
- // Přidání položky do seznamu nedávných položek - obaleno v useCallback
62
- const addToRecentItems = useCallback((item) => {
63
- if (!item || !moduleDefinition) return;
64
-
65
- // Vytvoření nového seznamu bez aktuální položky (pokud existuje)
66
- const filteredItems = recentItems.filter(
67
- (existingItem) =>
68
- existingItem[moduleDefinition.valueField || 'id'] !==
69
- item[moduleDefinition.valueField || 'id']
70
- );
71
-
72
- // Přidání nové položky na začátek a omezení počtu položek
73
- const updatedItems = [item, ...filteredItems].slice(0, maxRecentItems);
74
- setRecentItems(updatedItems);
75
- }, [recentItems, moduleDefinition, maxRecentItems, setRecentItems]);
76
-
77
- // Použití custom hooku pro veškerou logiku ModuleInput
78
- const {
79
- modalOpen,
80
- searchQuery,
81
- dropdownSearchQuery,
82
- rowData,
83
- fullListData,
84
- isLoading,
85
- isFullListLoading,
86
- selectedItem,
87
- isDetailLoading,
88
- moduleDetailQuery,
89
- openModal,
90
- closeModal,
91
- handleSearchChange,
92
- handleDropdownSearchChange,
93
- handleItemSelect,
94
- clearSelection,
95
- getDisplayValue,
96
- } = useModuleDropdownList({ moduleDefinition, value, displayMode });
97
-
98
- const selectItem = (item) => {
99
- const selectedItemData = handleItemSelect(item);
100
-
101
- // Uložení vybrané položky do nedávných položek
102
- if (selectedItemData) {
103
- addToRecentItems(selectedItemData);
104
- }
105
-
106
- // Přímé volání onChange s novou hodnotou a celým objektem
107
- if (typeof onChange === 'function' && moduleDefinition) {
108
- const id = selectedItemData[moduleDefinition.valueField || 'id'];
109
- onChange(id, selectedItemData);
110
- }
111
- };
112
-
113
- // Přidání vybrané položky do nedávných položek při načtení
114
- useEffect(() => {
115
- if (selectedItem && moduleDefinition) {
116
- addToRecentItems(selectedItem);
117
- }
118
- }, [selectedItem, moduleDefinition, addToRecentItems]);
119
-
120
- // Výběr položky přímo ze Select dropdown
121
- const handleSelectChange = (selectedId) => {
122
- if (!selectedId) {
123
- clearSelection();
124
- if (typeof onChange === 'function') {
125
- onChange(null, null);
126
- }
127
- } else {
128
- // Pokud je v nedávných položkách nebo fullListData, nastavte ji jako vybranou
129
- const allOptions = displayMode === 'full' ? fullListData : recentItems;
130
- const selectedItem = allOptions.find(
131
- (item) => item[moduleDefinition?.valueField || 'id'] === selectedId
132
- );
133
-
134
- if (selectedItem) {
135
- handleItemSelect(selectedItem);
136
- // Přímé volání onChange s ID a celým objektem
137
- if (typeof onChange === 'function') {
138
- onChange(selectedId, selectedItem);
139
- }
140
- }
141
- }
142
- };
143
-
144
- // Formátování nedávných položek pro zobrazení v Select
145
- const recentItemsOptions = recentItems.map((item) => ({
146
- id: item[moduleDefinition.valueField || 'id'],
147
- name: moduleDefinition?.getDisplayValue?.(item) || item.name || item.id,
148
- fullItem: item, // Uchování celé položky pro snadné získání později
149
- }));
150
-
151
- // Formátování kompletního seznamu pro zobrazení v Select (full mode)
152
- const fullListOptions = fullListData.map((item) => ({
153
- id: item[moduleDefinition.valueField || 'id'],
154
- name: moduleDefinition?.getDisplayValue?.(item) || item.name || item.id,
155
- fullItem: item,
156
- }));
157
-
158
- // Výběr správného seznamu podle displayMode
159
- const selectOptions = displayMode === 'full' ? fullListOptions : recentItemsOptions;
160
-
161
- // AutoComplete options pro free-text mód
162
- const autoCompleteOptions = selectOptions.map((option) => ({
163
- value: String(option.id),
164
- label: String(option.name || ''),
165
- }));
166
-
167
- // Pokud nemáme definici modulu, zobrazíme prázdné pole
168
- if (!moduleDefinition) return <div />;
169
-
170
- // Funkce pro výběr položky a zavření modálu
171
- const handleModalItemSelect = (event) => {
172
- selectItem(event.data);
173
- closeModal();
174
- };
175
-
176
- // Získání aktuální zobrazované hodnoty pro Select
177
- const displayValueText = getDisplayValue();
178
-
179
- // Funkce pro vykreslení obsahu Option při detailním načítání
180
- const renderDetailOption = () => {
181
- if (isDetailLoading) {
182
- return 'Načítání...';
183
- }
184
-
185
- if (moduleDetailQuery?.data) {
186
- return (
187
- <div className="option-item">
188
- <div className="option-avatar">
189
- {displayValueText.charAt(0)}
190
- </div>
191
- <div className="option-content">
192
- <div className="option-name">{displayValueText}</div>
193
- <div className="option-email">
194
- {moduleDetailQuery.data.email || '-'}
195
- </div>
196
- </div>
197
- </div>
198
- );
199
- }
200
-
201
- return `${value} (nenačteno)`;
202
- };
203
-
204
- return (
205
- <div
206
- className="module-input-container"
207
- style={processedStyle}
208
- ref={containerRef}
209
- >
210
- <div className="simple-select-container">
211
- {allowFreeText ? (
212
- <AutoComplete
213
- value={freeTextInput}
214
- options={autoCompleteOptions}
215
- style={{ width: '100%', fontSize: '13px' }}
216
- placeholder={placeholder}
217
- disabled={disabled || readOnly}
218
- getPopupContainer={() => document.body}
219
- dropdownStyle={{ zIndex: 9999 }}
220
- filterOption={(input, option) =>
221
- String(option?.label || '').toLowerCase().includes(input.toLowerCase())
222
- }
223
- onChange={(text) => {
224
- setFreeTextInput(text);
225
- onSearchChange?.(text);
226
- }}
227
- onSelect={(optionValue) => {
228
- justSelectedRef.current = true;
229
- const opt = selectOptions.find((o) => String(o.id) === optionValue);
230
- if (opt) {
231
- addToRecentItems(opt.fullItem);
232
- if (typeof onChange === 'function') {
233
- onChange(opt.id, opt.fullItem);
234
- }
235
- }
236
- }}
237
- onKeyDown={(e) => {
238
- if (e.key === 'Enter') {
239
- e.preventDefault();
240
- e.stopPropagation();
241
- if (freeTextInput) onFreeTextCommit?.(freeTextInput);
242
- }
243
- }}
244
- onBlur={() => {
245
- if (justSelectedRef.current) {
246
- justSelectedRef.current = false;
247
- return;
248
- }
249
- if (freeTextInput && !modalOpen) {
250
- onFreeTextCommit?.(freeTextInput);
251
- }
252
- }}
253
- />
254
- ) : (
255
- <Select
256
- value={value}
257
- onChange={handleSelectChange}
258
- className="simple-select"
259
- placeholder={placeholder}
260
- disabled={disabled || readOnly}
261
- dropdownMatchSelectWidth={false}
262
- getPopupContainer={() => document.body}
263
- dropdownStyle={{ zIndex: 9999 }}
264
- loading={displayMode === 'full' && isFullListLoading}
265
- style={{ width: '100%', fontSize: '13px' }}
266
- maxTagTextLength={20}
267
- filterOption={false}
268
- // Vlastní tučná šipka s rotací
269
- suffixIcon={
270
- <svg
271
- className="select-arrow-icon"
272
- width="12"
273
- height="12"
274
- viewBox="0 0 12 12"
275
- fill="none"
276
- xmlns="http://www.w3.org/2000/svg"
277
- >
278
- <path
279
- d="M2 4L6 8L10 4"
280
- stroke="#bfbfbf"
281
- strokeWidth="2.5"
282
- strokeLinecap="round"
283
- strokeLinejoin="round"
284
- />
285
- </svg>
286
- }
287
- optionLabelProp="label"
288
- listItemHeight={32}
289
- listHeight={256}
290
- dropdownRender={(menu) => (
291
- <div className="select-dropdown-container">
292
- {displayMode === 'full' && (
293
- <div className="search-box">
294
- <Input
295
- placeholder="Search"
296
- value={dropdownSearchQuery}
297
- onChange={(e) => handleDropdownSearchChange(e.target.value)}
298
- prefix={<SearchOutlined />}
299
- className="dropdown-search-input"
300
- />
301
- </div>
302
- )}
303
- {menu}
304
- <div className="new-customer-item">
305
- <div className="plus-icon">+</div>
306
- <span>New</span>
307
- </div>
308
- </div>
309
- )}
310
- >
311
- {selectOptions.map((option) => (
312
- <Option
313
- key={option.id}
314
- value={option.id}
315
- label={option.name}
316
- >
317
- {option.name}
318
- </Option>
319
- ))}
320
-
321
- {value && !selectOptions.some((option) => option.id === value) && (
322
- <Option key={value} value={value} label={displayValueText}>
323
- {renderDetailOption()}
324
- </Option>
325
- )}
326
- </Select>
327
- )}
328
-
329
- <Button
330
- icon={<SearchOutlined />}
331
- className="search-button"
332
- onClick={openModal}
333
- type="primary"
334
- />
335
- </div>
336
-
337
- {/* DraggableModal pro zobrazení tabulky */}
338
- <DraggableModal
339
- width={moduleDefinition.modalWidth || 800}
340
- open={modalOpen}
341
- getContainer={getContainer}
342
- onCancel={closeModal}
343
- maskClosable={false}
344
- zIndex={99999999}
345
- title={
346
- moduleDefinition.modalTitle ||
347
- `Vybrat z modulu: ${moduleDefinition.moduleName}`
348
- }
349
- footer={() => {
350
- return [
351
- <Button
352
- key="cancel"
353
- onClick={closeModal}
354
- style={{ marginRight: '10px', width: 'auto' }}
355
- >
356
- Zavřít
357
- </Button>,
358
- ];
359
- }}
360
- >
361
- <Input
362
- placeholder="Filtrovat..."
363
- value={searchQuery}
364
- onChange={(e) => handleSearchChange(e.target.value)}
365
- prefix={<SearchOutlined />}
366
- allowClear
367
- />
368
-
369
- {isLoading ? (
370
- <div className="text-align-center padding">Načítání...</div>
371
- ) : (
372
- <div
373
- style={{ height: 400, width: '100%' }}
374
- >
375
- <AgGrid
376
- theme="themeAlpine"
377
- rowData={rowData}
378
- columnDefs={moduleDefinition.columnDefs}
379
- defaultColDef={{
380
- sortable: true,
381
- filter: true,
382
- resizable: true,
383
- }}
384
- onCellDoubleClicked={handleModalItemSelect}
385
- rowSelection="single"
386
- enableCellChangeFlash={false}
387
- />
388
- </div>
389
- )}
390
- </DraggableModal>
391
- </div>
392
- );
393
- };
394
-
395
- export default ModuleDropdownList;
1
+ /* eslint-disable */
2
+ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
3
+ import AutoComplete from 'antd/es/auto-complete';
4
+ import Select from 'antd/es/select';
5
+ import Button from 'antd/es/button';
6
+ import Input from 'antd/es/input';
7
+ import { SearchOutlined, LinkOutlined, EditOutlined, DownOutlined, PlusOutlined } from '@ant-design/icons';
8
+ import { AgGridReact } from 'ag-grid-react';
9
+ import DraggableModal from '@bit.rhplus/draggable-modal';
10
+ import useData from '@bit.rhplus/data';
11
+ import { useOidcAccessToken } from '@axa-fr/react-oidc';
12
+
13
+ // Session-level cache — data načtena jednou per moduleName per session
14
+ const _cache = {};
15
+
16
+ // ─── Detail panel subkomponenta ───────────────────────────────────────────────
17
+ const DetailPanel = ({ definition, item }) => {
18
+ if (!definition || !item) return null;
19
+ if (typeof definition.render === 'function') return definition.render(item);
20
+ if (!Array.isArray(definition.fields)) return null;
21
+ if (!definition.fields.length) return null;
22
+ return (
23
+ <div
24
+ style={{
25
+ background: '#fafafa',
26
+ border: '1px solid #d9d9d9',
27
+ borderRadius: 6,
28
+ padding: '8px 12px',
29
+ display: 'grid',
30
+ gridTemplateColumns: '1fr 1fr',
31
+ gap: '4px 16px',
32
+ fontSize: 12,
33
+ }}
34
+ >
35
+ {definition.fields.map((f) => (
36
+ <div key={f.key} style={{ display: 'flex', gap: 4, minWidth: 0 }}>
37
+ <span style={{ color: '#8c8c8c', flexShrink: 0 }}>{f.label}:</span>
38
+ <span
39
+ style={{
40
+ fontWeight: 500,
41
+ overflow: 'hidden',
42
+ textOverflow: 'ellipsis',
43
+ whiteSpace: 'nowrap',
44
+ color: item[f.key] ? 'inherit' : '#bfbfbf',
45
+ }}
46
+ >
47
+ {item[f.key] ?? ''}
48
+ </span>
49
+ </div>
50
+ ))}
51
+ </div>
52
+ );
53
+ };
54
+
55
+ const ModuleDropdownList = ({
56
+ value,
57
+ onChange,
58
+ onFreeTextCommit,
59
+ placeholder = 'Vyberte',
60
+ disabled = false,
61
+ style = {},
62
+ allowFreeText = true,
63
+ createNewOnMismatch = false,
64
+ showArrow = true,
65
+ showAddButton = true,
66
+ getContainer = () => document.body,
67
+ moduleDefinition,
68
+ }) => {
69
+ const moduleName = moduleDefinition?.moduleName;
70
+
71
+ const { fetchDataUIAsync } = useData();
72
+ const { accessToken } = useOidcAccessToken();
73
+
74
+ // ─── Data a načítání ──────────────────────────────────────────────────────────
75
+ const [allItems, setAllItems] = useState(() => _cache[moduleName] || []);
76
+ const [allItemsLoading, setAllItemsLoading] = useState(!_cache[moduleName]);
77
+
78
+ useEffect(() => {
79
+ if (!moduleName || !moduleDefinition) return;
80
+ if (_cache[moduleName]) {
81
+ setAllItems(_cache[moduleName]);
82
+ setAllItemsLoading(false);
83
+ return;
84
+ }
85
+ setAllItemsLoading(true);
86
+ moduleDefinition
87
+ .fetchData('', fetchDataUIAsync, accessToken)
88
+ .then((data) => {
89
+ const items = Array.isArray(data) ? data : [];
90
+ _cache[moduleName] = items;
91
+ setAllItems(items);
92
+ })
93
+ .catch(() => setAllItems([]))
94
+ .finally(() => setAllItemsLoading(false));
95
+ }, []); // pouze při mount
96
+
97
+ // ─── Free-text stav ──────────────────────────────────────────────────────────
98
+ const initialText = useMemo(() => {
99
+ if (!allowFreeText) return '';
100
+ if (typeof value === 'string') return value;
101
+ return '';
102
+ }, []); // pouze při mount
103
+
104
+ const [inputText, setInputText] = useState(initialText);
105
+ const [dropdownOpen, setDropdownOpen] = useState(false);
106
+ // null = prázdno/psaní, true = katalog (🔗), false = volný text (✏️)
107
+ const [isLinked, setIsLinked] = useState(null);
108
+ const [selectedItem, setSelectedItem] = useState(null);
109
+ const [detailOpen, setDetailOpen] = useState(false);
110
+ const justSelectedRef = useRef(false);
111
+ const freeTextWrapperRef = useRef(null);
112
+ const inputTextRef = useRef(initialText);
113
+ inputTextRef.current = inputText;
114
+ const isLinkedRef = useRef(null);
115
+ isLinkedRef.current = isLinked;
116
+ // Ref na aktuální suggestions — přístupný v capture handleru bez stale closure
117
+ const suggestionsRef = useRef([]);
118
+ // Ref na handleAutoCompleteSelect — přístupný v capture handleru bez stale closure
119
+ const handleAutoCompleteSelectRef = useRef(null);
120
+
121
+ // ─── Sync externího value (načtení formuláře) ────────────────────────────────
122
+ // Když se value změní zvenku (form load), najde položku v katalogu a zobrazí ji.
123
+ // Pokud položka není v allItems, zkusí fetchById fallback.
124
+ useEffect(() => {
125
+ console.log('[ModuleDropdownList] sync value:', { value, allItemsCount: allItems.length, allItemsLoading, moduleName });
126
+ if (!value) return;
127
+ // Pokud allItems ještě loading, počkáme na další run
128
+ if (allItemsLoading) return;
129
+
130
+ const vf = moduleDefinition?.valueField || 'id';
131
+
132
+ const showItem = (item) => {
133
+ console.log('[ModuleDropdownList] showItem:', { id: item?.[vf], displayValue: moduleDefinition?.getDisplayValue?.(item) });
134
+ const displayText =
135
+ moduleDefinition?.getDisplayValue?.(item) || item.code || item.name || '';
136
+ setInputText(displayText);
137
+ setIsLinked(true);
138
+ setSelectedItem(item);
139
+ };
140
+
141
+ if (allItems.length) {
142
+ const item = allItems.find((i) => String(i[vf]) === String(value));
143
+ if (item) {
144
+ showItem(item);
145
+ return;
146
+ }
147
+ }
148
+
149
+ // Fallback: fetchById pro položky, které nejsou v seznamu
150
+ if (moduleDefinition?.fetchById) {
151
+ moduleDefinition.fetchById(value, fetchDataUIAsync, accessToken).then((item) => {
152
+ if (item) {
153
+ showItem(item);
154
+ }
155
+ });
156
+ }
157
+ }, [value, allItems, allItemsLoading]); // eslint-disable-line react-hooks/exhaustive-deps
158
+
159
+ // ─── Create-new modal ─────────────────────────────────────────────────────────
160
+ const [createNewModalOpen, setCreateNewModalOpen] = useState(false);
161
+ const [createNewInitialName, setCreateNewInitialName] = useState('');
162
+
163
+ // ─── Modaly ───────────────────────────────────────────────────────────────────
164
+ const [catalogFilter, setCatalogFilter] = useState('');
165
+ const [catalogModalOpen, setCatalogModalOpen] = useState(false);
166
+ const [multiMatchData, setMultiMatchData] = useState([]);
167
+ const [multiMatchModalOpen, setMultiMatchModalOpen] = useState(false);
168
+ const catalogModalOpenRef = useRef(false);
169
+ catalogModalOpenRef.current = catalogModalOpen;
170
+ const multiMatchModalOpenRef = useRef(false);
171
+ multiMatchModalOpenRef.current = multiMatchModalOpen;
172
+
173
+ // ─── Lokální filtrování návrhů ────────────────────────────────────────────────
174
+ const suggestions = useMemo(() => {
175
+ const vf = moduleDefinition?.valueField || 'id';
176
+ const source =
177
+ !inputText || !inputText.trim()
178
+ ? allItems
179
+ : allItems.filter((item) => {
180
+ const label =
181
+ moduleDefinition?.getDisplayValue?.(item) ||
182
+ item.code ||
183
+ item.name ||
184
+ '';
185
+ return label.toLowerCase().includes(inputText.toLowerCase());
186
+ });
187
+ return source.slice(0, 15).map((item) => ({
188
+ value: String(item[vf]),
189
+ label:
190
+ moduleDefinition?.getDisplayValue?.(item) ||
191
+ item.code ||
192
+ item.name ||
193
+ '',
194
+ item,
195
+ }));
196
+ }, [allItems, inputText, moduleDefinition]);
197
+ suggestionsRef.current = suggestions;
198
+
199
+ // Catalog-only: options pro Select
200
+ const catalogOptions = useMemo(() => {
201
+ const vf = moduleDefinition?.valueField || 'id';
202
+ return allItems.map((item) => ({
203
+ value: item[vf],
204
+ label:
205
+ moduleDefinition?.getDisplayValue?.(item) ||
206
+ item.code ||
207
+ item.name ||
208
+ String(item[vf]),
209
+ }));
210
+ }, [allItems, moduleDefinition]);
211
+
212
+ // ─── Výběr z katalogu (shared) ────────────────────────────────────────────────
213
+ const commitCatalogItem = useCallback(
214
+ (item) => {
215
+ if (!item) return;
216
+ const vf = moduleDefinition?.valueField || 'id';
217
+ const id = item[vf];
218
+ setSelectedItem(item);
219
+ if (typeof onChange === 'function') {
220
+ onChange(id, item);
221
+ }
222
+ },
223
+ [moduleDefinition, onChange]
224
+ );
225
+
226
+ const handleCreateSuccess = useCallback(
227
+ (newItem) => {
228
+ if (newItem) {
229
+ _cache[moduleName] = [...(_cache[moduleName] || []), newItem];
230
+ setAllItems((prev) => [...prev, newItem]);
231
+ const displayText =
232
+ moduleDefinition?.getDisplayValue?.(newItem) || newItem.name || '';
233
+ setInputText(displayText);
234
+ setIsLinked(true);
235
+ commitCatalogItem(newItem);
236
+ }
237
+ setCreateNewModalOpen(false);
238
+ },
239
+ [moduleName, moduleDefinition, commitCatalogItem]
240
+ );
241
+
242
+ // ─── Catalog-only: výběr ze Select ───────────────────────────────────────────
243
+ const handleCatalogSelectChange = useCallback(
244
+ (selectedId) => {
245
+ if (!selectedId) {
246
+ if (typeof onChange === 'function') onChange(null, null);
247
+ return;
248
+ }
249
+ const vf = moduleDefinition?.valueField || 'id';
250
+ const item = allItems.find((i) => String(i[vf]) === String(selectedId));
251
+ if (item) commitCatalogItem(item);
252
+ },
253
+ [allItems, moduleDefinition, commitCatalogItem, onChange]
254
+ );
255
+
256
+ // ─── Výběr z catalog modalu ───────────────────────────────────────────────────
257
+ const handleCatalogModalSelect = useCallback(
258
+ (params) => {
259
+ if (!params.data) return;
260
+ const item = params.data;
261
+ if (allowFreeText) {
262
+ const displayText =
263
+ moduleDefinition?.getDisplayValue?.(item) ||
264
+ item.code ||
265
+ item.name ||
266
+ '';
267
+ setInputText(displayText);
268
+ }
269
+ isLinkedRef.current = true; // synchronní update před zavřením modalu (onCancel race condition)
270
+ setIsLinked(true);
271
+ commitCatalogItem(item);
272
+ setCatalogModalOpen(false);
273
+ },
274
+ [allowFreeText, moduleDefinition, commitCatalogItem]
275
+ );
276
+
277
+ // ─── Free-text: výběr návrhu z AutoComplete ───────────────────────────────────
278
+ const handleAutoCompleteSelect = useCallback(
279
+ (optionValue, option) => {
280
+ justSelectedRef.current = true;
281
+ const item = option.item;
282
+ const displayText =
283
+ moduleDefinition?.getDisplayValue?.(item) ||
284
+ item.code ||
285
+ item.name ||
286
+ '';
287
+ setInputText(displayText);
288
+ setDropdownOpen(false);
289
+ setIsLinked(true);
290
+ commitCatalogItem(item);
291
+ // Ztratit focus z inputu po výběru
292
+ freeTextWrapperRef.current?.querySelector('input')?.blur();
293
+ },
294
+ [moduleDefinition, commitCatalogItem]
295
+ );
296
+ handleAutoCompleteSelectRef.current = handleAutoCompleteSelect;
297
+
298
+ // ─── Free-text: validace po Enter / blur ──────────────────────────────────────
299
+ const handleFreeTextCommit = useCallback(
300
+ (text) => {
301
+ if (!text || !text.trim()) {
302
+ if (typeof onChange === 'function') onChange(null, null);
303
+ return;
304
+ }
305
+
306
+ const searchLower = text.trim().toLowerCase();
307
+ const vf = moduleDefinition?.valueField || 'id';
308
+ const localMatches = allItems.filter((item) => {
309
+ const label =
310
+ moduleDefinition?.getDisplayValue?.(item) ||
311
+ item.code ||
312
+ item.name ||
313
+ '';
314
+ return label.toLowerCase() === searchLower;
315
+ });
316
+
317
+ if (localMatches.length === 0) {
318
+ if (createNewOnMismatch && moduleDefinition?.createFormComponent) {
319
+ // Create-new mód — otevřít formulář pro vytvoření nového záznamu
320
+ setCreateNewInitialName(text.trim());
321
+ setCreateNewModalOpen(true);
322
+ } else {
323
+ // Volný text — žádná shoda v katalogu
324
+ setIsLinked(false);
325
+ setSelectedItem(null);
326
+ setDetailOpen(false);
327
+ if (typeof onFreeTextCommit === 'function') {
328
+ onFreeTextCommit(text.trim());
329
+ }
330
+ if (typeof onChange === 'function') onChange(null, null);
331
+ freeTextWrapperRef.current?.querySelector('input')?.blur();
332
+ }
333
+ } else if (localMatches.length === 1) {
334
+ // Přesná jedna shoda → auto-výběr
335
+ const item = localMatches[0];
336
+ const displayText =
337
+ moduleDefinition?.getDisplayValue?.(item) ||
338
+ item.code ||
339
+ item.name ||
340
+ text.trim();
341
+ setInputText(displayText);
342
+ setIsLinked(true);
343
+ commitCatalogItem(item);
344
+ } else {
345
+ // Více shod → multi-match modal
346
+ setMultiMatchData(localMatches);
347
+ setMultiMatchModalOpen(true);
348
+ }
349
+ },
350
+ [allItems, moduleDefinition, onChange, onFreeTextCommit, commitCatalogItem]
351
+ );
352
+
353
+ // ─── Free-text: blur ──────────────────────────────────────────────────────────
354
+ const handleBlur = useCallback(() => {
355
+ if (justSelectedRef.current) {
356
+ justSelectedRef.current = false;
357
+ return;
358
+ }
359
+ // Pokud už byla hodnota commitnuta (katalog / autocomplete), neoverwritovat
360
+ if (isLinkedRef.current === true) return;
361
+ // Refy místo state — vždy aktuální hodnota, bez stale closure problémů
362
+ if (inputTextRef.current && !multiMatchModalOpenRef.current && !catalogModalOpenRef.current) {
363
+ handleFreeTextCommit(inputTextRef.current);
364
+ }
365
+ }, [handleFreeTextCommit]);
366
+
367
+ // ─── Free-text: výběr z multi-match modalu ────────────────────────────────────
368
+ const handleMultiMatchSelect = useCallback(
369
+ (params) => {
370
+ if (!params.data) return;
371
+ const item = params.data;
372
+ const displayText =
373
+ moduleDefinition?.getDisplayValue?.(item) ||
374
+ item.code ||
375
+ item.name ||
376
+ '';
377
+ setInputText(displayText);
378
+ isLinkedRef.current = true; // synchronní update před zavřením modalu (onCancel race condition)
379
+ setIsLinked(true);
380
+ setMultiMatchModalOpen(false);
381
+ commitCatalogItem(item);
382
+ },
383
+ [moduleDefinition, commitCatalogItem]
384
+ );
385
+
386
+ // ─── Native capture Enter handler (rc-select stopPropagation obrana) ─────────
387
+ useEffect(() => {
388
+ if (!allowFreeText) return;
389
+ const el = freeTextWrapperRef.current;
390
+ if (!el) return;
391
+ const input = el.querySelector('input');
392
+ if (!input) return;
393
+
394
+ const handler = (e) => {
395
+ if (e.key !== 'Enter') return;
396
+
397
+ // Pokud je v dropdown označená položka šipkou, ručně ji vybereme
398
+ const activeOptionEl = document.querySelector('.ant-select-item-option-active');
399
+ if (activeOptionEl) {
400
+ e.stopImmediatePropagation();
401
+ e.preventDefault();
402
+ const labelText = activeOptionEl.querySelector('.ant-select-item-option-content')?.textContent?.trim();
403
+ if (labelText) {
404
+ const matched = suggestionsRef.current.find((s) => s.label === labelText);
405
+ if (matched) {
406
+ handleAutoCompleteSelectRef.current(matched.value, matched);
407
+ return;
408
+ }
409
+ }
410
+ return;
411
+ }
412
+
413
+ // Žádná aktivní položka → volný text
414
+ const text = inputTextRef.current || '';
415
+ if (!text.trim()) return;
416
+ e.stopImmediatePropagation();
417
+ e.preventDefault();
418
+ handleFreeTextCommit(text);
419
+ };
420
+
421
+ input.addEventListener('keydown', handler, true);
422
+ return () => input.removeEventListener('keydown', handler, true);
423
+ }, [allowFreeText, handleFreeTextCommit]);
424
+
425
+ // ─── Shared: create-new modal ────────────────────────────────────────────────
426
+ const createNewModalContent = moduleDefinition?.createFormComponent ? (
427
+ <DraggableModal
428
+ open={createNewModalOpen}
429
+ title={moduleDefinition.createModalTitle || 'Nový záznam'}
430
+ width={moduleDefinition.createModalWidth || 600}
431
+ onCancel={() => setCreateNewModalOpen(false)}
432
+ maskClosable={false}
433
+ getContainer={getContainer}
434
+ zIndex={99999999}
435
+ footer={null}
436
+ >
437
+ {React.createElement(moduleDefinition.createFormComponent, {
438
+ initialName: createNewInitialName,
439
+ onSuccess: handleCreateSuccess,
440
+ onCancel: () => setCreateNewModalOpen(false),
441
+ fetchDataUIAsync,
442
+ accessToken,
443
+ createRecord: moduleDefinition.createRecord,
444
+ })}
445
+ </DraggableModal>
446
+ ) : null;
447
+
448
+ const addButton = showAddButton && moduleDefinition?.createFormComponent ? (
449
+ <Button
450
+ icon={<PlusOutlined />}
451
+ onMouseDown={(e) => e.preventDefault()}
452
+ onClick={() => {
453
+ setCreateNewInitialName('');
454
+ setCreateNewModalOpen(true);
455
+ }}
456
+ disabled={disabled}
457
+ />
458
+ ) : null;
459
+
460
+ const searchButton = (onClickFn) => (
461
+ <Button
462
+ icon={<SearchOutlined />}
463
+ onMouseDown={(e) => e.preventDefault()}
464
+ onClick={onClickFn}
465
+ disabled={disabled}
466
+ />
467
+ );
468
+
469
+ // ─── Shared: catalog modal ────────────────────────────────────────────────────
470
+ const catalogModalContent = (
471
+ <DraggableModal
472
+ open={catalogModalOpen}
473
+ title={
474
+ moduleDefinition?.modalTitle ||
475
+ `Vybrat z modulu: ${moduleDefinition?.moduleName}`
476
+ }
477
+ width={moduleDefinition?.modalWidth || 800}
478
+ onCancel={() => {
479
+ setCatalogModalOpen(false);
480
+ setCatalogFilter('');
481
+ // Pokud uživatel zavřel modal bez výběru a text není katalogově propojený, commitni ho
482
+ if (allowFreeText && inputTextRef.current && isLinkedRef.current !== true) {
483
+ setTimeout(() => handleFreeTextCommit(inputTextRef.current), 0);
484
+ }
485
+ }}
486
+ maskClosable={false}
487
+ getContainer={getContainer}
488
+ zIndex={99999999}
489
+ footer={null}
490
+ >
491
+ {allItemsLoading ? (
492
+ <div style={{ padding: 24, textAlign: 'center' }}>Načítání...</div>
493
+ ) : (
494
+ <>
495
+ <Input
496
+ prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
497
+ placeholder="Hledat..."
498
+ value={catalogFilter}
499
+ onChange={(e) => setCatalogFilter(e.target.value)}
500
+ allowClear
501
+ style={{ marginBottom: 8 }}
502
+ />
503
+ <div className="ag-theme-alpine" style={{ height: 400, width: '100%' }}>
504
+ <AgGridReact
505
+ columnDefs={moduleDefinition?.columnDefs || []}
506
+ rowData={allItems}
507
+ quickFilterText={catalogFilter}
508
+ onRowDoubleClicked={(params) => {
509
+ handleCatalogModalSelect(params);
510
+ setCatalogFilter('');
511
+ }}
512
+ />
513
+ </div>
514
+ </>
515
+ )}
516
+ </DraggableModal>
517
+ );
518
+
519
+ // ─── Render: catalog-only mód ─────────────────────────────────────────────────
520
+ if (!allowFreeText) {
521
+ const vf = moduleDefinition?.valueField || 'id';
522
+ const currentId =
523
+ value && typeof value === 'object' ? value[vf] : value;
524
+
525
+ return (
526
+ <div style={{ display: 'flex', gap: 4, ...style }}>
527
+ <Select
528
+ style={{ flex: 1 }}
529
+ value={currentId}
530
+ options={catalogOptions}
531
+ loading={allItemsLoading}
532
+ showSearch
533
+ filterOption={(input, option) =>
534
+ String(option?.label || '')
535
+ .toLowerCase()
536
+ .includes(input.toLowerCase())
537
+ }
538
+ onChange={handleCatalogSelectChange}
539
+ getPopupContainer={() => document.body}
540
+ dropdownStyle={{ zIndex: 9999 }}
541
+ placeholder={placeholder}
542
+ disabled={disabled}
543
+ />
544
+ {addButton}
545
+ {searchButton(() => {
546
+ catalogModalOpenRef.current = true;
547
+ setCatalogModalOpen(true);
548
+ })}
549
+ {catalogModalContent}
550
+ {createNewModalContent}
551
+ </div>
552
+ );
553
+ }
554
+
555
+ // ─── Render: free-text mód ────────────────────────────────────────────────────
556
+ return (
557
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
558
+ {/* Hlavní řádek — omezený stylem z props */}
559
+ <div style={{ display: 'flex', gap: 4, alignItems: 'center', ...style }}>
560
+ <div ref={freeTextWrapperRef} style={{ flex: 1, position: 'relative' }}>
561
+ {moduleDefinition?.detailPanel && showArrow && (
562
+ <DownOutlined
563
+ onMouseDown={(e) => e.preventDefault()}
564
+ onClick={() => selectedItem && setDetailOpen((v) => !v)}
565
+ style={{
566
+ position: 'absolute',
567
+ left: -18,
568
+ top: '50%',
569
+ transform: `translateY(-50%) rotate(${detailOpen ? 180 : 0}deg)`,
570
+ transition: 'transform 0.2s',
571
+ fontSize: 11,
572
+ cursor: selectedItem ? 'pointer' : 'default',
573
+ color: selectedItem ? 'rgba(0,0,0,0.45)' : '#d9d9d9',
574
+ zIndex: 1,
575
+ pointerEvents: selectedItem ? 'auto' : 'none',
576
+ }}
577
+ />
578
+ )}
579
+ <AutoComplete
580
+ style={{ width: '100%' }}
581
+ value={inputText}
582
+ options={suggestions}
583
+ open={dropdownOpen && suggestions.length > 0}
584
+ defaultActiveFirstOption={false}
585
+ allowClear
586
+ onChange={(text) => {
587
+ setInputText(text || '');
588
+ setIsLinked(null);
589
+ setSelectedItem(null);
590
+ setDetailOpen(false);
591
+ }}
592
+ onSelect={handleAutoCompleteSelect}
593
+ onFocus={() => setDropdownOpen(true)}
594
+ onBlur={() => {
595
+ setTimeout(() => setDropdownOpen(false), 150);
596
+ handleBlur();
597
+ }}
598
+ notFoundContent={allItemsLoading ? 'Načítání...' : null}
599
+ placeholder={placeholder}
600
+ disabled={disabled}
601
+ getPopupContainer={() => document.body}
602
+ dropdownStyle={{ zIndex: 9999 }}
603
+ />
604
+ </div>
605
+
606
+ {!createNewOnMismatch && isLinked === true && (
607
+ <LinkOutlined style={{ color: '#52c41a', alignSelf: 'center', fontSize: 14, flexShrink: 0 }} />
608
+ )}
609
+ {!createNewOnMismatch && isLinked === false && inputText && (
610
+ <EditOutlined style={{ color: '#faad14', alignSelf: 'center', fontSize: 14, flexShrink: 0 }} />
611
+ )}
612
+
613
+ {addButton}
614
+ {searchButton(() => {
615
+ catalogModalOpenRef.current = true;
616
+ setCatalogModalOpen(true);
617
+ })}
618
+ </div>
619
+
620
+ {/* Detail panel — 100% šířky parent containeru, nezávisle na šířce inputu */}
621
+ {moduleDefinition?.detailPanel && detailOpen && selectedItem && (
622
+ <DetailPanel definition={moduleDefinition.detailPanel} item={selectedItem} />
623
+ )}
624
+
625
+ {catalogModalContent}
626
+ {createNewModalContent}
627
+
628
+ {/* Multi-match modal — více shod na zadaný text */}
629
+ <DraggableModal
630
+ open={multiMatchModalOpen}
631
+ title="Nalezeno více shod"
632
+ width={moduleDefinition?.modalWidth || 800}
633
+ onCancel={() => setMultiMatchModalOpen(false)}
634
+ maskClosable={false}
635
+ getContainer={getContainer}
636
+ zIndex={99999999}
637
+ footer={null}
638
+ >
639
+ <div className="ag-theme-alpine" style={{ height: 400, width: '100%' }}>
640
+ <AgGridReact
641
+ columnDefs={moduleDefinition?.columnDefs || []}
642
+ rowData={multiMatchData}
643
+ onRowDoubleClicked={handleMultiMatchSelect}
644
+ />
645
+ </div>
646
+ </DraggableModal>
647
+
648
+ </div>
649
+ );
650
+ };
651
+
652
+ export default ModuleDropdownList;
653
+ /* eslint-enable */