@apptimate/ui 3.5.0 → 3.7.0
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/package.json +3 -2
- package/src/base-components/Input.tsx +6 -3
- package/src/base-components/TagsInput.tsx +106 -0
- package/src/base-components/WizardModal.tsx +162 -0
- package/src/common-components/item-wizard/ItemFormWizard.tsx +705 -0
- package/src/common-components/item-wizard/SkuConfigModal.tsx +314 -0
- package/src/common-components/pickers/BrandPicker.tsx +194 -0
- package/src/common-components/pickers/CategoryPicker.tsx +342 -0
- package/src/common-components/pickers/EntityPickerModal.tsx +221 -0
- package/src/common-components/pickers/PartyPicker.tsx +231 -0
- package/src/common-components/pickers/TransactionItemPicker.tsx +166 -0
- package/src/common-components/pickers/UomGroupPicker.tsx +196 -0
- package/src/common-components/pickers/UomPicker.tsx +306 -0
- package/src/common-components/pickers/WarehousePicker.tsx +135 -0
- package/src/common-components/pickers/index.ts +11 -0
- package/src/common-components/pickers/modals/BatchSelectionModal.tsx +359 -0
- package/src/common-components/pickers/modals/SerialSelectionModal.tsx +247 -0
- package/src/common-components/pickers/modals/types.ts +30 -0
- package/src/common-components/transaction/MetadataPanel.tsx +417 -0
- package/src/common-components/transaction/ProductSelectionPanel.tsx +587 -0
- package/src/common-components/transaction/ProductTransactionScreen.tsx +250 -0
- package/src/common-components/transaction/index.ts +17 -0
- package/src/common-components/transaction/types.ts +300 -0
- package/src/components/shared/ImageUploadComponent.tsx +1 -2
- package/src/index.tsx +20 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useCallback, useEffect } from "react";
|
|
4
|
+
import { Button, Input, Modal, ModalFooter } from "@apptimate/ui";
|
|
5
|
+
import { Plus, List, GitBranch, ChevronRight, ChevronDown, FolderOpen } from "lucide-react";
|
|
6
|
+
import {
|
|
7
|
+
EntityPickerModal,
|
|
8
|
+
PickerItem,
|
|
9
|
+
PickerTrigger,
|
|
10
|
+
} from "./EntityPickerModal";
|
|
11
|
+
import { lookupCategories, getCategoryTree, createCategory } from "@apptimate/core-lib";
|
|
12
|
+
import toast from "react-hot-toast";
|
|
13
|
+
|
|
14
|
+
/* ──────────────────────────────────────────────────────────────────────────
|
|
15
|
+
* CategoryPicker
|
|
16
|
+
*
|
|
17
|
+
* Popup selector for categories with:
|
|
18
|
+
* - Tree view (default) / flat list view toggle
|
|
19
|
+
* - Inline search
|
|
20
|
+
* - Quick-add new category
|
|
21
|
+
* ────────────────────────────────────────────────────────────────────── */
|
|
22
|
+
|
|
23
|
+
interface CategoryPickerProps {
|
|
24
|
+
/** Currently selected category ID */
|
|
25
|
+
value?: number | string | null;
|
|
26
|
+
/** Display name of the selected category */
|
|
27
|
+
displayValue?: string | null;
|
|
28
|
+
/** Called when a category is selected – receives { id, name } */
|
|
29
|
+
onChange: (category: { id: number; name: string } | null) => void;
|
|
30
|
+
/** Field label */
|
|
31
|
+
label?: string;
|
|
32
|
+
/** Placeholder text */
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
/** Required field? */
|
|
35
|
+
isRequired?: boolean;
|
|
36
|
+
/** Category ID to exclude (e.g. the current category in edit mode) */
|
|
37
|
+
excludeId?: number | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function CategoryPicker({
|
|
41
|
+
value,
|
|
42
|
+
displayValue,
|
|
43
|
+
onChange,
|
|
44
|
+
label = "Category",
|
|
45
|
+
placeholder = "Select category…",
|
|
46
|
+
isRequired = false,
|
|
47
|
+
excludeId = null,
|
|
48
|
+
}: CategoryPickerProps) {
|
|
49
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
50
|
+
const [search, setSearch] = useState("");
|
|
51
|
+
const [viewMode, setViewMode] = useState<"tree" | "list">("tree");
|
|
52
|
+
const [treeData, setTreeData] = useState<any[]>([]);
|
|
53
|
+
const [flatData, setFlatData] = useState<any[]>([]);
|
|
54
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
55
|
+
|
|
56
|
+
// Quick add
|
|
57
|
+
const [isQuickAddOpen, setIsQuickAddOpen] = useState(false);
|
|
58
|
+
const [quickAddName, setQuickAddName] = useState("");
|
|
59
|
+
const [isCreating, setIsCreating] = useState(false);
|
|
60
|
+
|
|
61
|
+
// Expanded tree nodes
|
|
62
|
+
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
|
63
|
+
|
|
64
|
+
const fetchData = useCallback(async () => {
|
|
65
|
+
setIsLoading(true);
|
|
66
|
+
try {
|
|
67
|
+
const [treeRes, flatRes] = await Promise.all([
|
|
68
|
+
getCategoryTree(),
|
|
69
|
+
lookupCategories(),
|
|
70
|
+
]);
|
|
71
|
+
if (treeRes.is_success) setTreeData(treeRes.result || []);
|
|
72
|
+
if (flatRes.is_success) setFlatData(flatRes.result || []);
|
|
73
|
+
|
|
74
|
+
// Auto-expand all in tree on load
|
|
75
|
+
if (treeRes.is_success && treeRes.result) {
|
|
76
|
+
const allIds = new Set<number>();
|
|
77
|
+
const collectIds = (nodes: any[]) => {
|
|
78
|
+
nodes.forEach((n) => {
|
|
79
|
+
if (n.children_tree?.length > 0) {
|
|
80
|
+
allIds.add(n.id);
|
|
81
|
+
collectIds(n.children_tree);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
collectIds(treeRes.result);
|
|
86
|
+
setExpandedIds(allIds);
|
|
87
|
+
}
|
|
88
|
+
} catch {}
|
|
89
|
+
setIsLoading(false);
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
const handleOpen = () => {
|
|
93
|
+
setSearch("");
|
|
94
|
+
setIsOpen(true);
|
|
95
|
+
fetchData();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const handleSelect = (cat: any) => {
|
|
99
|
+
onChange({ id: cat.id, name: cat.name });
|
|
100
|
+
setIsOpen(false);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleClear = () => {
|
|
104
|
+
onChange(null);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const toggleExpand = (id: number) => {
|
|
108
|
+
setExpandedIds((prev) => {
|
|
109
|
+
const next = new Set(prev);
|
|
110
|
+
if (next.has(id)) next.delete(id);
|
|
111
|
+
else next.add(id);
|
|
112
|
+
return next;
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Quick add
|
|
117
|
+
const handleQuickAdd = async () => {
|
|
118
|
+
if (!quickAddName.trim()) return;
|
|
119
|
+
setIsCreating(true);
|
|
120
|
+
try {
|
|
121
|
+
const res = await createCategory({ name: quickAddName.trim(), status: "active" });
|
|
122
|
+
if (res.is_success && res.result) {
|
|
123
|
+
toast.success("Category created");
|
|
124
|
+
onChange({ id: res.result.id, name: res.result.name });
|
|
125
|
+
setIsQuickAddOpen(false);
|
|
126
|
+
setQuickAddName("");
|
|
127
|
+
setIsOpen(false);
|
|
128
|
+
} else {
|
|
129
|
+
toast.error(res.message || "Failed to create");
|
|
130
|
+
}
|
|
131
|
+
} catch (e: any) {
|
|
132
|
+
toast.error(e.message || "Error creating category");
|
|
133
|
+
}
|
|
134
|
+
setIsCreating(false);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// ── Filter helpers ──
|
|
138
|
+
|
|
139
|
+
const searchLower = search.toLowerCase();
|
|
140
|
+
|
|
141
|
+
const filterFlat = (items: any[]) =>
|
|
142
|
+
items.filter(
|
|
143
|
+
(c) =>
|
|
144
|
+
c.name.toLowerCase().includes(searchLower) &&
|
|
145
|
+
c.id !== excludeId
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const filterTree = (nodes: any[]): any[] =>
|
|
149
|
+
nodes
|
|
150
|
+
.filter((n) => n.id !== excludeId)
|
|
151
|
+
.map((n) => ({
|
|
152
|
+
...n,
|
|
153
|
+
children_tree: filterTree(n.children_tree || []),
|
|
154
|
+
}))
|
|
155
|
+
.filter(
|
|
156
|
+
(n) =>
|
|
157
|
+
n.name.toLowerCase().includes(searchLower) ||
|
|
158
|
+
n.children_tree.length > 0
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// ── Tree node renderer ──
|
|
162
|
+
|
|
163
|
+
const renderTreeNode = (node: any, depth: number = 0): React.ReactNode => {
|
|
164
|
+
const hasChildren = node.children_tree && node.children_tree.length > 0;
|
|
165
|
+
const isExpanded = expandedIds.has(node.id);
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div key={node.id}>
|
|
169
|
+
<div className="flex items-center">
|
|
170
|
+
{hasChildren ? (
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
onClick={(e) => { e.stopPropagation(); toggleExpand(node.id); }}
|
|
174
|
+
className="p-1 rounded hover:bg-gray-100 text-gray-400 flex-shrink-0"
|
|
175
|
+
style={{ marginLeft: `${depth * 20}px` }}
|
|
176
|
+
>
|
|
177
|
+
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
178
|
+
</button>
|
|
179
|
+
) : (
|
|
180
|
+
<div style={{ marginLeft: `${depth * 20 + 22}px` }} />
|
|
181
|
+
)}
|
|
182
|
+
<div className="flex-1">
|
|
183
|
+
<PickerItem
|
|
184
|
+
label={node.name}
|
|
185
|
+
sublabel={node.code || undefined}
|
|
186
|
+
isSelected={String(value) === String(node.id)}
|
|
187
|
+
onClick={() => handleSelect(node)}
|
|
188
|
+
trailing={
|
|
189
|
+
hasChildren ? (
|
|
190
|
+
<span className="text-[11px] text-gray-400 font-medium">
|
|
191
|
+
{node.children_tree.length}
|
|
192
|
+
</span>
|
|
193
|
+
) : undefined
|
|
194
|
+
}
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
{hasChildren && isExpanded && (
|
|
199
|
+
<div>
|
|
200
|
+
{node.children_tree.map((child: any) => renderTreeNode(child, depth + 1))}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const filteredTree = search ? filterTree(treeData) : treeData;
|
|
208
|
+
const filteredFlat = filterFlat(flatData);
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<>
|
|
212
|
+
<PickerTrigger
|
|
213
|
+
label={label}
|
|
214
|
+
value={displayValue || null}
|
|
215
|
+
placeholder={placeholder}
|
|
216
|
+
isRequired={isRequired}
|
|
217
|
+
onClick={handleOpen}
|
|
218
|
+
onClear={value ? handleClear : undefined}
|
|
219
|
+
/>
|
|
220
|
+
|
|
221
|
+
<EntityPickerModal
|
|
222
|
+
isOpen={isOpen}
|
|
223
|
+
onClose={() => setIsOpen(false)}
|
|
224
|
+
onSelect={handleSelect}
|
|
225
|
+
title="Select Category"
|
|
226
|
+
searchPlaceholder="Search categories…"
|
|
227
|
+
selectedId={value}
|
|
228
|
+
search={search}
|
|
229
|
+
onSearchChange={setSearch}
|
|
230
|
+
size="md"
|
|
231
|
+
headerActions={
|
|
232
|
+
<div className="flex items-center gap-1 flex-shrink-0">
|
|
233
|
+
{/* View toggle */}
|
|
234
|
+
<div className="flex items-center bg-gray-100 rounded-lg p-0.5">
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
onClick={() => setViewMode("tree")}
|
|
238
|
+
className={`p-1.5 rounded-md transition-all ${
|
|
239
|
+
viewMode === "tree"
|
|
240
|
+
? "bg-white text-gray-800 shadow-sm"
|
|
241
|
+
: "text-gray-400 hover:text-gray-600"
|
|
242
|
+
}`}
|
|
243
|
+
title="Tree View"
|
|
244
|
+
>
|
|
245
|
+
<GitBranch size={15} />
|
|
246
|
+
</button>
|
|
247
|
+
<button
|
|
248
|
+
type="button"
|
|
249
|
+
onClick={() => setViewMode("list")}
|
|
250
|
+
className={`p-1.5 rounded-md transition-all ${
|
|
251
|
+
viewMode === "list"
|
|
252
|
+
? "bg-white text-gray-800 shadow-sm"
|
|
253
|
+
: "text-gray-400 hover:text-gray-600"
|
|
254
|
+
}`}
|
|
255
|
+
title="List View"
|
|
256
|
+
>
|
|
257
|
+
<List size={15} />
|
|
258
|
+
</button>
|
|
259
|
+
</div>
|
|
260
|
+
{/* Quick add */}
|
|
261
|
+
<button
|
|
262
|
+
type="button"
|
|
263
|
+
onClick={() => { setIsQuickAddOpen(true); setQuickAddName(""); }}
|
|
264
|
+
className="p-1.5 rounded-lg bg-primary-50 text-primary-600 hover:bg-primary-100 transition-colors"
|
|
265
|
+
title="Quick Add Category"
|
|
266
|
+
>
|
|
267
|
+
<Plus size={16} strokeWidth={2.5} />
|
|
268
|
+
</button>
|
|
269
|
+
</div>
|
|
270
|
+
}
|
|
271
|
+
>
|
|
272
|
+
{isLoading ? (
|
|
273
|
+
<div className="py-12 text-center">
|
|
274
|
+
<div className="inline-block h-6 w-6 rounded-full border-2 border-gray-200 border-t-primary-500 animate-spin" />
|
|
275
|
+
<p className="mt-3 text-sm text-gray-400">Loading categories…</p>
|
|
276
|
+
</div>
|
|
277
|
+
) : viewMode === "tree" ? (
|
|
278
|
+
filteredTree.length === 0 ? (
|
|
279
|
+
<div className="py-12 text-center">
|
|
280
|
+
<FolderOpen size={32} className="mx-auto text-gray-300 mb-2" />
|
|
281
|
+
<p className="text-sm text-gray-400">No categories found</p>
|
|
282
|
+
</div>
|
|
283
|
+
) : (
|
|
284
|
+
<div className="space-y-0.5 py-1">
|
|
285
|
+
{filteredTree.map((node) => renderTreeNode(node, 0))}
|
|
286
|
+
</div>
|
|
287
|
+
)
|
|
288
|
+
) : filteredFlat.length === 0 ? (
|
|
289
|
+
<div className="py-12 text-center">
|
|
290
|
+
<FolderOpen size={32} className="mx-auto text-gray-300 mb-2" />
|
|
291
|
+
<p className="text-sm text-gray-400">No categories found</p>
|
|
292
|
+
</div>
|
|
293
|
+
) : (
|
|
294
|
+
<div className="space-y-0.5 py-1">
|
|
295
|
+
{filteredFlat.map((cat) => (
|
|
296
|
+
<PickerItem
|
|
297
|
+
key={cat.id}
|
|
298
|
+
label={cat.name}
|
|
299
|
+
sublabel={cat.parent_id ? `Child of #${cat.parent_id}` : "Root"}
|
|
300
|
+
isSelected={String(value) === String(cat.id)}
|
|
301
|
+
onClick={() => handleSelect(cat)}
|
|
302
|
+
/>
|
|
303
|
+
))}
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
306
|
+
</EntityPickerModal>
|
|
307
|
+
|
|
308
|
+
{/* Quick Add Modal */}
|
|
309
|
+
<Modal
|
|
310
|
+
isOpen={isQuickAddOpen}
|
|
311
|
+
onClose={() => setIsQuickAddOpen(false)}
|
|
312
|
+
title="Quick Add Category"
|
|
313
|
+
size="sm"
|
|
314
|
+
zIndex={300}
|
|
315
|
+
backdrop="blur"
|
|
316
|
+
>
|
|
317
|
+
<div className="space-y-4">
|
|
318
|
+
<Input
|
|
319
|
+
label="Category Name"
|
|
320
|
+
isRequired
|
|
321
|
+
placeholder="Enter category name"
|
|
322
|
+
value={quickAddName}
|
|
323
|
+
onChange={(e) => setQuickAddName(e.target.value)}
|
|
324
|
+
onKeyDown={(e) => { if (e.key === "Enter") handleQuickAdd(); }}
|
|
325
|
+
/>
|
|
326
|
+
<ModalFooter>
|
|
327
|
+
<Button
|
|
328
|
+
variant="flat"
|
|
329
|
+
color="default"
|
|
330
|
+
onClick={() => setIsQuickAddOpen(false)}
|
|
331
|
+
>
|
|
332
|
+
Cancel
|
|
333
|
+
</Button>
|
|
334
|
+
<Button onClick={handleQuickAdd} isDisabled={isCreating || !quickAddName.trim()}>
|
|
335
|
+
{isCreating ? "Creating…" : "Create & Select"}
|
|
336
|
+
</Button>
|
|
337
|
+
</ModalFooter>
|
|
338
|
+
</div>
|
|
339
|
+
</Modal>
|
|
340
|
+
</>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useCallback, ReactNode } from "react";
|
|
4
|
+
import { Modal, Input, Button } from "@apptimate/ui";
|
|
5
|
+
import { Search, Plus, X, Check } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
/* ──────────────────────────────────────────────────────────────────────────
|
|
8
|
+
* EntityPickerModal
|
|
9
|
+
*
|
|
10
|
+
* A generalized popup-selector for entities. Shows a modal with:
|
|
11
|
+
* - Search bar
|
|
12
|
+
* - Configurable header actions (view toggles, quick-add button)
|
|
13
|
+
* - Scrollable list of selectable items rendered via a render prop
|
|
14
|
+
* - The selected item is highlighted and returned via onSelect
|
|
15
|
+
*
|
|
16
|
+
* Each concrete picker (CategoryPicker, BrandPicker, WarehousePicker, …)
|
|
17
|
+
* wraps this component and supplies its own data-fetching, rendering,
|
|
18
|
+
* and quick-add logic.
|
|
19
|
+
* ────────────────────────────────────────────────────────────────────── */
|
|
20
|
+
|
|
21
|
+
export interface EntityPickerModalProps {
|
|
22
|
+
/** Modal visible state */
|
|
23
|
+
isOpen: boolean;
|
|
24
|
+
/** Called when user dismisses without selecting */
|
|
25
|
+
onClose: () => void;
|
|
26
|
+
/** Called with the selected entity data when user clicks an item */
|
|
27
|
+
onSelect?: (entity: any) => void;
|
|
28
|
+
/** Title shown in the modal header */
|
|
29
|
+
title: string;
|
|
30
|
+
/** Current search query – managed internally if not provided */
|
|
31
|
+
searchPlaceholder?: string;
|
|
32
|
+
/** Currently selected entity ID (for highlighting) */
|
|
33
|
+
selectedId?: number | string | null;
|
|
34
|
+
/** Extra header actions – view toggles, quick-add, etc. */
|
|
35
|
+
headerActions?: ReactNode;
|
|
36
|
+
/** The scrollable body content */
|
|
37
|
+
children: ReactNode;
|
|
38
|
+
/** Search value */
|
|
39
|
+
search: string;
|
|
40
|
+
/** Search change handler */
|
|
41
|
+
onSearchChange: (value: string) => void;
|
|
42
|
+
/** Show the search bar – default true */
|
|
43
|
+
showSearch?: boolean;
|
|
44
|
+
/** Modal size */
|
|
45
|
+
size?: "sm" | "md" | "lg" | "xl" | "full";
|
|
46
|
+
/** Custom zIndex */
|
|
47
|
+
zIndex?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function EntityPickerModal({
|
|
51
|
+
isOpen,
|
|
52
|
+
onClose,
|
|
53
|
+
title,
|
|
54
|
+
searchPlaceholder = "Search…",
|
|
55
|
+
headerActions,
|
|
56
|
+
children,
|
|
57
|
+
search,
|
|
58
|
+
onSearchChange,
|
|
59
|
+
showSearch = true,
|
|
60
|
+
size = "md",
|
|
61
|
+
zIndex = 200,
|
|
62
|
+
}: EntityPickerModalProps) {
|
|
63
|
+
return (
|
|
64
|
+
<Modal isOpen={isOpen} onClose={onClose} title={title} size={size} zIndex={zIndex} backdrop="blur">
|
|
65
|
+
<div className="flex flex-col gap-3 -mt-1">
|
|
66
|
+
{/* Search + actions row */}
|
|
67
|
+
<div className="flex items-center gap-2">
|
|
68
|
+
{showSearch && (
|
|
69
|
+
<div className="relative flex-1">
|
|
70
|
+
<Search
|
|
71
|
+
size={16}
|
|
72
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
|
|
73
|
+
/>
|
|
74
|
+
<input
|
|
75
|
+
type="text"
|
|
76
|
+
placeholder={searchPlaceholder}
|
|
77
|
+
value={search}
|
|
78
|
+
onChange={(e) => onSearchChange(e.target.value)}
|
|
79
|
+
className="w-full pl-9 pr-8 py-2.5 bg-surface-0 border-[1.5px] border-border-subtle rounded-[10px] text-[13.5px] text-foreground-1 outline-none transition-all hover:border-gray-300 focus:border-gray-300 focus:bg-surface-1 placeholder:text-foreground-disabled"
|
|
80
|
+
autoFocus
|
|
81
|
+
/>
|
|
82
|
+
{search && (
|
|
83
|
+
<button
|
|
84
|
+
onClick={() => onSearchChange("")}
|
|
85
|
+
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
86
|
+
>
|
|
87
|
+
<X size={14} />
|
|
88
|
+
</button>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
{headerActions}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{/* Scrollable list body */}
|
|
96
|
+
<div className="max-h-[400px] overflow-y-auto bg-gray-50/80 -mx-6 px-4 py-2 border-t border-gray-100">
|
|
97
|
+
{children}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</Modal>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ──────────────────────────────────────────────────────────────────────────
|
|
105
|
+
* PickerItem – a single clickable row inside the picker
|
|
106
|
+
* ────────────────────────────────────────────────────────────────────── */
|
|
107
|
+
|
|
108
|
+
export interface PickerItemProps {
|
|
109
|
+
label: string;
|
|
110
|
+
sublabel?: string;
|
|
111
|
+
isSelected?: boolean;
|
|
112
|
+
onClick: () => void;
|
|
113
|
+
indent?: number;
|
|
114
|
+
trailing?: ReactNode;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function PickerItem({
|
|
118
|
+
label,
|
|
119
|
+
sublabel,
|
|
120
|
+
isSelected = false,
|
|
121
|
+
onClick,
|
|
122
|
+
indent = 0,
|
|
123
|
+
trailing,
|
|
124
|
+
}: PickerItemProps) {
|
|
125
|
+
return (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
onClick={onClick}
|
|
129
|
+
className={`
|
|
130
|
+
w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all duration-100 group
|
|
131
|
+
${isSelected
|
|
132
|
+
? "bg-primary-50 border border-primary-200 text-primary-700"
|
|
133
|
+
: "hover:bg-gray-50 border border-transparent text-gray-700"
|
|
134
|
+
}
|
|
135
|
+
`}
|
|
136
|
+
style={{ paddingLeft: `${12 + indent * 20}px` }}
|
|
137
|
+
>
|
|
138
|
+
{/* Selection indicator */}
|
|
139
|
+
<div
|
|
140
|
+
className={`
|
|
141
|
+
flex-shrink-0 h-5 w-5 rounded-full border-2 flex items-center justify-center transition-all
|
|
142
|
+
${isSelected
|
|
143
|
+
? "border-primary-500 bg-primary-500"
|
|
144
|
+
: "border-gray-300 group-hover:border-gray-400"
|
|
145
|
+
}
|
|
146
|
+
`}
|
|
147
|
+
>
|
|
148
|
+
{isSelected && <Check size={12} className="text-white" strokeWidth={3} />}
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div className="flex-1 min-w-0">
|
|
152
|
+
<p className={`text-sm font-semibold truncate ${isSelected ? "text-primary-700" : "text-gray-800"}`}>
|
|
153
|
+
{label}
|
|
154
|
+
</p>
|
|
155
|
+
{sublabel && (
|
|
156
|
+
<p className="text-[11px] text-gray-400 truncate mt-0.5">{sublabel}</p>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{trailing}
|
|
161
|
+
</button>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ──────────────────────────────────────────────────────────────────────────
|
|
166
|
+
* PickerTrigger – the clickable field that opens the picker modal
|
|
167
|
+
* ────────────────────────────────────────────────────────────────────── */
|
|
168
|
+
|
|
169
|
+
export interface PickerTriggerProps {
|
|
170
|
+
label: string;
|
|
171
|
+
value?: string | null;
|
|
172
|
+
placeholder?: string;
|
|
173
|
+
isRequired?: boolean;
|
|
174
|
+
onClick: () => void;
|
|
175
|
+
onClear?: () => void;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function PickerTrigger({
|
|
179
|
+
label,
|
|
180
|
+
value,
|
|
181
|
+
placeholder = "Select…",
|
|
182
|
+
isRequired = false,
|
|
183
|
+
onClick,
|
|
184
|
+
onClear,
|
|
185
|
+
}: PickerTriggerProps) {
|
|
186
|
+
return (
|
|
187
|
+
<div className="flex flex-col gap-1.5 w-full">
|
|
188
|
+
<label className="text-[11px] font-bold text-foreground-subtle uppercase tracking-wider">
|
|
189
|
+
{label}
|
|
190
|
+
{isRequired && <span className="text-danger-alt ml-0.5">*</span>}
|
|
191
|
+
</label>
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
onClick={onClick}
|
|
195
|
+
className={`
|
|
196
|
+
w-full flex items-center justify-between px-3.5 py-2.5 bg-surface-0 border-[1.5px] rounded-[10px] text-[13.5px] text-left transition-all
|
|
197
|
+
${value
|
|
198
|
+
? "border-border-subtle text-foreground-1"
|
|
199
|
+
: "border-border-subtle text-foreground-disabled"
|
|
200
|
+
}
|
|
201
|
+
hover:border-gray-300 focus:outline-none focus:border-gray-300 focus:bg-surface-1
|
|
202
|
+
`}
|
|
203
|
+
>
|
|
204
|
+
<span className={`truncate ${value ? "font-medium" : ""}`}>
|
|
205
|
+
{value || placeholder}
|
|
206
|
+
</span>
|
|
207
|
+
<div className="flex items-center gap-1 flex-shrink-0">
|
|
208
|
+
{value && onClear && (
|
|
209
|
+
<span
|
|
210
|
+
onClick={(e) => { e.stopPropagation(); onClear(); }}
|
|
211
|
+
className="p-0.5 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600"
|
|
212
|
+
>
|
|
213
|
+
<X size={14} />
|
|
214
|
+
</span>
|
|
215
|
+
)}
|
|
216
|
+
<Search size={14} className="text-gray-400" />
|
|
217
|
+
</div>
|
|
218
|
+
</button>
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
}
|