@apptimate/ui 3.5.0 → 3.6.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.
Files changed (25) hide show
  1. package/package.json +3 -2
  2. package/src/base-components/Input.tsx +6 -3
  3. package/src/base-components/TagsInput.tsx +106 -0
  4. package/src/base-components/WizardModal.tsx +162 -0
  5. package/src/common-components/item-wizard/ItemFormWizard.tsx +705 -0
  6. package/src/common-components/item-wizard/SkuConfigModal.tsx +314 -0
  7. package/src/common-components/pickers/BrandPicker.tsx +194 -0
  8. package/src/common-components/pickers/CategoryPicker.tsx +342 -0
  9. package/src/common-components/pickers/EntityPickerModal.tsx +221 -0
  10. package/src/common-components/pickers/PartyPicker.tsx +231 -0
  11. package/src/common-components/pickers/TransactionItemPicker.tsx +166 -0
  12. package/src/common-components/pickers/UomGroupPicker.tsx +196 -0
  13. package/src/common-components/pickers/UomPicker.tsx +306 -0
  14. package/src/common-components/pickers/WarehousePicker.tsx +129 -0
  15. package/src/common-components/pickers/index.ts +11 -0
  16. package/src/common-components/pickers/modals/BatchSelectionModal.tsx +359 -0
  17. package/src/common-components/pickers/modals/SerialSelectionModal.tsx +247 -0
  18. package/src/common-components/pickers/modals/types.ts +30 -0
  19. package/src/common-components/transaction/MetadataPanel.tsx +417 -0
  20. package/src/common-components/transaction/ProductSelectionPanel.tsx +587 -0
  21. package/src/common-components/transaction/ProductTransactionScreen.tsx +250 -0
  22. package/src/common-components/transaction/index.ts +17 -0
  23. package/src/common-components/transaction/types.ts +300 -0
  24. package/src/components/shared/ImageUploadComponent.tsx +1 -2
  25. 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
+ }