@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.
- 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 +129 -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,231 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useCallback, useEffect } from "react";
|
|
4
|
+
import { Button, Input, Modal, ModalFooter } from "@apptimate/ui";
|
|
5
|
+
import { Plus, User } from "lucide-react";
|
|
6
|
+
import {
|
|
7
|
+
EntityPickerModal,
|
|
8
|
+
PickerItem,
|
|
9
|
+
PickerTrigger,
|
|
10
|
+
} from "./EntityPickerModal";
|
|
11
|
+
import { lookupParties, createParty } from "@apptimate/core-lib";
|
|
12
|
+
import toast from "react-hot-toast";
|
|
13
|
+
|
|
14
|
+
interface PartyPickerProps {
|
|
15
|
+
value?: number | string | null;
|
|
16
|
+
displayValue?: string | null;
|
|
17
|
+
onChange: (party: { id: number; name: string; code?: string; type?: string } | null) => void;
|
|
18
|
+
label?: string;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
isRequired?: boolean;
|
|
21
|
+
partyType?: "customer" | "supplier" | "all";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function PartyPicker({
|
|
25
|
+
value,
|
|
26
|
+
displayValue,
|
|
27
|
+
onChange,
|
|
28
|
+
label = "Party",
|
|
29
|
+
placeholder = "Select party…",
|
|
30
|
+
isRequired = false,
|
|
31
|
+
partyType = "all",
|
|
32
|
+
}: PartyPickerProps) {
|
|
33
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
34
|
+
const [search, setSearch] = useState("");
|
|
35
|
+
const [parties, setParties] = useState<any[]>([]);
|
|
36
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
37
|
+
|
|
38
|
+
// Quick add
|
|
39
|
+
const [isQuickAddOpen, setIsQuickAddOpen] = useState(false);
|
|
40
|
+
const [quickAddFirstName, setQuickAddFirstName] = useState("");
|
|
41
|
+
const [quickAddLastName, setQuickAddLastName] = useState("");
|
|
42
|
+
const [quickAddEmail, setQuickAddEmail] = useState("");
|
|
43
|
+
const [quickAddPhone, setQuickAddPhone] = useState("");
|
|
44
|
+
const [isCreating, setIsCreating] = useState(false);
|
|
45
|
+
|
|
46
|
+
const fetchData = useCallback(async () => {
|
|
47
|
+
setIsLoading(true);
|
|
48
|
+
try {
|
|
49
|
+
const typeParam = partyType === "all" ? undefined : partyType;
|
|
50
|
+
const res = await lookupParties(typeParam);
|
|
51
|
+
if (res.is_success) {
|
|
52
|
+
const data = res.result?.data || res.result || [];
|
|
53
|
+
setParties(data.map((p: any) => ({ ...p, name: p.name || p.full_name || [p.first_name, p.last_name].filter(Boolean).join(" ") })));
|
|
54
|
+
}
|
|
55
|
+
} catch {}
|
|
56
|
+
setIsLoading(false);
|
|
57
|
+
}, [partyType]);
|
|
58
|
+
|
|
59
|
+
const handleOpen = () => {
|
|
60
|
+
setSearch("");
|
|
61
|
+
setIsOpen(true);
|
|
62
|
+
fetchData();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const handleSelect = (party: any) => {
|
|
66
|
+
onChange({ id: party.id, name: party.name, code: party.code, type: party.type });
|
|
67
|
+
setIsOpen(false);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const handleClear = () => {
|
|
71
|
+
onChange(null);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleQuickAdd = async () => {
|
|
75
|
+
if (!quickAddFirstName.trim()) return;
|
|
76
|
+
setIsCreating(true);
|
|
77
|
+
try {
|
|
78
|
+
const payload = {
|
|
79
|
+
first_name: quickAddFirstName.trim(),
|
|
80
|
+
last_name: quickAddLastName.trim() || undefined,
|
|
81
|
+
email: quickAddEmail.trim() || undefined,
|
|
82
|
+
primary_contact: quickAddPhone.trim() || undefined,
|
|
83
|
+
type: [partyType === "all" ? "customer" : partyType],
|
|
84
|
+
status: "active"
|
|
85
|
+
};
|
|
86
|
+
const res = await createParty(payload);
|
|
87
|
+
if (res.is_success && res.result) {
|
|
88
|
+
toast.success("Party created");
|
|
89
|
+
const party = res.result;
|
|
90
|
+
const partyName = party.name || party.full_name || [party.first_name, party.last_name].filter(Boolean).join(" ");
|
|
91
|
+
onChange({ id: party.id, name: partyName, code: party.code, type: party.type });
|
|
92
|
+
setIsQuickAddOpen(false);
|
|
93
|
+
setQuickAddFirstName("");
|
|
94
|
+
setQuickAddLastName("");
|
|
95
|
+
setQuickAddEmail("");
|
|
96
|
+
setQuickAddPhone("");
|
|
97
|
+
setIsOpen(false);
|
|
98
|
+
} else {
|
|
99
|
+
toast.error(res.message || "Failed to create");
|
|
100
|
+
}
|
|
101
|
+
} catch (e: any) {
|
|
102
|
+
toast.error(e.message || "Error creating party");
|
|
103
|
+
}
|
|
104
|
+
setIsCreating(false);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const searchLower = (search || "").toLowerCase();
|
|
108
|
+
const filtered = parties.filter((p) =>
|
|
109
|
+
(p.name || "").toLowerCase().includes(searchLower) || (p.code || "").toLowerCase().includes(searchLower)
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<>
|
|
114
|
+
<PickerTrigger
|
|
115
|
+
label={label}
|
|
116
|
+
value={displayValue || null}
|
|
117
|
+
placeholder={placeholder}
|
|
118
|
+
isRequired={isRequired}
|
|
119
|
+
onClick={handleOpen}
|
|
120
|
+
onClear={value ? handleClear : undefined}
|
|
121
|
+
/>
|
|
122
|
+
|
|
123
|
+
<EntityPickerModal
|
|
124
|
+
isOpen={isOpen}
|
|
125
|
+
onClose={() => setIsOpen(false)}
|
|
126
|
+
onSelect={handleSelect}
|
|
127
|
+
title={`Select ${label}`}
|
|
128
|
+
searchPlaceholder={`Search ${label.toLowerCase()}s…`}
|
|
129
|
+
selectedId={value}
|
|
130
|
+
search={search}
|
|
131
|
+
onSearchChange={setSearch}
|
|
132
|
+
size="sm"
|
|
133
|
+
headerActions={
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onClick={() => {
|
|
137
|
+
setIsQuickAddOpen(true);
|
|
138
|
+
setQuickAddFirstName("");
|
|
139
|
+
setQuickAddLastName("");
|
|
140
|
+
setQuickAddEmail("");
|
|
141
|
+
setQuickAddPhone("");
|
|
142
|
+
}}
|
|
143
|
+
className="p-1.5 rounded-lg bg-primary-50 text-primary-600 hover:bg-primary-100 transition-colors flex-shrink-0"
|
|
144
|
+
title={`Quick Add ${label}`}
|
|
145
|
+
>
|
|
146
|
+
<Plus size={16} strokeWidth={2.5} />
|
|
147
|
+
</button>
|
|
148
|
+
}
|
|
149
|
+
>
|
|
150
|
+
{isLoading ? (
|
|
151
|
+
<div className="py-12 text-center">
|
|
152
|
+
<div className="inline-block h-6 w-6 rounded-full border-2 border-gray-200 border-t-primary-500 animate-spin" />
|
|
153
|
+
<p className="mt-3 text-sm text-gray-400">Loading {label.toLowerCase()}s…</p>
|
|
154
|
+
</div>
|
|
155
|
+
) : filtered.length === 0 ? (
|
|
156
|
+
<div className="py-12 text-center">
|
|
157
|
+
<User size={32} className="mx-auto text-gray-300 mb-2" />
|
|
158
|
+
<p className="text-sm text-gray-400">No {label.toLowerCase()}s found</p>
|
|
159
|
+
</div>
|
|
160
|
+
) : (
|
|
161
|
+
<div className="space-y-0.5 py-1">
|
|
162
|
+
{filtered.map((party) => (
|
|
163
|
+
<PickerItem
|
|
164
|
+
key={party.id}
|
|
165
|
+
label={party.name}
|
|
166
|
+
sublabel={party.code ? `Code: ${party.code}` : undefined}
|
|
167
|
+
isSelected={String(value) === String(party.id)}
|
|
168
|
+
onClick={() => handleSelect(party)}
|
|
169
|
+
/>
|
|
170
|
+
))}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
</EntityPickerModal>
|
|
174
|
+
|
|
175
|
+
{/* Quick Add Modal */}
|
|
176
|
+
<Modal
|
|
177
|
+
isOpen={isQuickAddOpen}
|
|
178
|
+
onClose={() => setIsQuickAddOpen(false)}
|
|
179
|
+
title={`Quick Add ${label}`}
|
|
180
|
+
size="md"
|
|
181
|
+
zIndex={300}
|
|
182
|
+
backdrop="blur"
|
|
183
|
+
>
|
|
184
|
+
<div className="space-y-4">
|
|
185
|
+
<div className="grid grid-cols-2 gap-3">
|
|
186
|
+
<Input
|
|
187
|
+
label="First Name"
|
|
188
|
+
isRequired
|
|
189
|
+
placeholder={`Enter ${label.toLowerCase()} first name`}
|
|
190
|
+
value={quickAddFirstName}
|
|
191
|
+
onChange={(e) => setQuickAddFirstName(e.target.value)}
|
|
192
|
+
/>
|
|
193
|
+
<Input
|
|
194
|
+
label="Last Name"
|
|
195
|
+
placeholder={`Enter ${label.toLowerCase()} last name`}
|
|
196
|
+
value={quickAddLastName}
|
|
197
|
+
onChange={(e) => setQuickAddLastName(e.target.value)}
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
<div className="grid grid-cols-2 gap-3">
|
|
201
|
+
<Input
|
|
202
|
+
label="Email"
|
|
203
|
+
type="email"
|
|
204
|
+
placeholder="Email address"
|
|
205
|
+
value={quickAddEmail}
|
|
206
|
+
onChange={(e) => setQuickAddEmail(e.target.value)}
|
|
207
|
+
/>
|
|
208
|
+
<Input
|
|
209
|
+
label="Phone"
|
|
210
|
+
placeholder="Phone number"
|
|
211
|
+
value={quickAddPhone}
|
|
212
|
+
onChange={(e) => setQuickAddPhone(e.target.value)}
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
<ModalFooter>
|
|
216
|
+
<Button
|
|
217
|
+
variant="flat"
|
|
218
|
+
color="default"
|
|
219
|
+
onClick={() => setIsQuickAddOpen(false)}
|
|
220
|
+
>
|
|
221
|
+
Cancel
|
|
222
|
+
</Button>
|
|
223
|
+
<Button onClick={handleQuickAdd} isDisabled={isCreating || !quickAddFirstName.trim()}>
|
|
224
|
+
{isCreating ? "Creating…" : "Create & Select"}
|
|
225
|
+
</Button>
|
|
226
|
+
</ModalFooter>
|
|
227
|
+
</div>
|
|
228
|
+
</Modal>
|
|
229
|
+
</>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
4
|
+
import { EntityPickerModal } from "./EntityPickerModal";
|
|
5
|
+
import { pickerSearchItems } from "@apptimate/core-lib";
|
|
6
|
+
import { ChevronRight, Package, Box, Hash } from "lucide-react";
|
|
7
|
+
import { useDebounce } from "use-debounce";
|
|
8
|
+
import { Badge } from "../../base-components/Badge";
|
|
9
|
+
|
|
10
|
+
interface TransactionItemPickerProps {
|
|
11
|
+
isOpen: boolean;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
/** Called when a specific variant, serial, batch, or item is selected */
|
|
14
|
+
onSelect: (selection: {
|
|
15
|
+
type: "item" | "variant" | "serial" | "batch";
|
|
16
|
+
entity: any;
|
|
17
|
+
parentItem?: any;
|
|
18
|
+
}) => void;
|
|
19
|
+
warehouseId?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function TransactionItemPicker({
|
|
23
|
+
isOpen,
|
|
24
|
+
onClose,
|
|
25
|
+
onSelect,
|
|
26
|
+
warehouseId,
|
|
27
|
+
}: TransactionItemPickerProps) {
|
|
28
|
+
const [search, setSearch] = useState("");
|
|
29
|
+
const [debouncedSearch] = useDebounce(search, 300);
|
|
30
|
+
const [items, setItems] = useState<any[]>([]);
|
|
31
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
32
|
+
const [expandedItemId, setExpandedItemId] = useState<number | null>(null);
|
|
33
|
+
|
|
34
|
+
const fetchItems = useCallback(async (query: string) => {
|
|
35
|
+
setIsLoading(true);
|
|
36
|
+
try {
|
|
37
|
+
const res = await pickerSearchItems(query, warehouseId, 50);
|
|
38
|
+
if (res.is_success) {
|
|
39
|
+
// Check for exact match
|
|
40
|
+
if (res.result?.exact_match) {
|
|
41
|
+
const exact = res.result.exact_match;
|
|
42
|
+
onSelect({
|
|
43
|
+
type: exact.type,
|
|
44
|
+
entity: exact.entity,
|
|
45
|
+
parentItem: exact.type === "item" ? exact.entity : exact.entity.item,
|
|
46
|
+
});
|
|
47
|
+
onClose();
|
|
48
|
+
return; // Stop rendering list
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setItems(res.result?.data || []);
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
setIsLoading(false);
|
|
55
|
+
}
|
|
56
|
+
}, [warehouseId, onSelect, onClose]);
|
|
57
|
+
|
|
58
|
+
// Initial fetch and on search change
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (isOpen) {
|
|
61
|
+
fetchItems(debouncedSearch);
|
|
62
|
+
}
|
|
63
|
+
}, [isOpen, debouncedSearch, fetchItems]);
|
|
64
|
+
|
|
65
|
+
const handleSelect = (type: "item" | "variant", entity: any, parentItem?: any) => {
|
|
66
|
+
onSelect({ type, entity, parentItem: parentItem || entity });
|
|
67
|
+
onClose();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<EntityPickerModal
|
|
72
|
+
isOpen={isOpen}
|
|
73
|
+
onClose={onClose}
|
|
74
|
+
title="Select Item"
|
|
75
|
+
search={search}
|
|
76
|
+
onSearchChange={setSearch}
|
|
77
|
+
searchPlaceholder="Search by name, SKU, barcode, batch, or serial..."
|
|
78
|
+
size="lg"
|
|
79
|
+
>
|
|
80
|
+
{isLoading ? (
|
|
81
|
+
<div className="p-4 text-center text-sm text-gray-500">Searching...</div>
|
|
82
|
+
) : items.length === 0 ? (
|
|
83
|
+
<div className="p-8 text-center text-sm text-gray-500">No items found.</div>
|
|
84
|
+
) : (
|
|
85
|
+
<div className="flex flex-col gap-1 py-1">
|
|
86
|
+
{items.map((item) => (
|
|
87
|
+
<div key={item.id} className="flex flex-col">
|
|
88
|
+
{/* Item Header / Row */}
|
|
89
|
+
<button
|
|
90
|
+
onClick={() => {
|
|
91
|
+
if (item.has_variants) {
|
|
92
|
+
setExpandedItemId(expandedItemId === item.id ? null : item.id);
|
|
93
|
+
} else {
|
|
94
|
+
handleSelect("item", item);
|
|
95
|
+
}
|
|
96
|
+
}}
|
|
97
|
+
className={`flex items-center gap-3 w-full p-2.5 rounded-[8px] hover:bg-white hover:shadow-sm border border-transparent hover:border-gray-100 transition-all text-left ${expandedItemId === item.id ? 'bg-white shadow-sm border-gray-100' : ''}`}
|
|
98
|
+
>
|
|
99
|
+
<div className="w-8 h-8 rounded bg-gray-100 flex items-center justify-center shrink-0 border border-gray-200">
|
|
100
|
+
{item.image_url ? (
|
|
101
|
+
<img src={item.image_url} alt="" className="w-full h-full object-cover rounded" />
|
|
102
|
+
) : (
|
|
103
|
+
<Package size={16} className="text-gray-400" />
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div className="flex-1 min-w-0">
|
|
108
|
+
<div className="text-[13px] font-semibold text-gray-900 truncate">
|
|
109
|
+
{item.name}
|
|
110
|
+
</div>
|
|
111
|
+
<div className="text-[11px] text-gray-500 flex items-center gap-2">
|
|
112
|
+
<span className="font-mono">{item.sku}</span>
|
|
113
|
+
{item.has_variants && (
|
|
114
|
+
<Badge variant="flat" color="default" size="small">
|
|
115
|
+
{item.variants?.length || 0} variants
|
|
116
|
+
</Badge>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{item.has_variants ? (
|
|
122
|
+
<ChevronRight
|
|
123
|
+
size={16}
|
|
124
|
+
className={`text-gray-400 transition-transform ${expandedItemId === item.id ? 'rotate-90' : ''}`}
|
|
125
|
+
/>
|
|
126
|
+
) : (
|
|
127
|
+
<div className="text-[12px] font-medium text-gray-900">
|
|
128
|
+
Select
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
</button>
|
|
132
|
+
|
|
133
|
+
{/* Variants Tree */}
|
|
134
|
+
{item.has_variants && expandedItemId === item.id && (
|
|
135
|
+
<div className="pl-12 pr-2 py-2 flex flex-col gap-1 border-l-2 border-gray-100 ml-6 mt-1 mb-2">
|
|
136
|
+
{item.variants?.map((variant: any) => (
|
|
137
|
+
<button
|
|
138
|
+
key={variant.id}
|
|
139
|
+
onClick={() => handleSelect("variant", variant, item)}
|
|
140
|
+
className="flex items-center justify-between p-2 rounded-[6px] hover:bg-gray-100 transition-colors text-left group"
|
|
141
|
+
>
|
|
142
|
+
<div className="flex flex-col">
|
|
143
|
+
<span className="text-[12.5px] font-medium text-gray-800">
|
|
144
|
+
{variant.variant_name}
|
|
145
|
+
</span>
|
|
146
|
+
<span className="text-[11px] font-mono text-gray-500">
|
|
147
|
+
{variant.sku}
|
|
148
|
+
</span>
|
|
149
|
+
</div>
|
|
150
|
+
<span className="text-[11px] font-medium text-primary-600 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
151
|
+
Select
|
|
152
|
+
</span>
|
|
153
|
+
</button>
|
|
154
|
+
))}
|
|
155
|
+
{(!item.variants || item.variants.length === 0) && (
|
|
156
|
+
<div className="text-[12px] text-gray-400 italic py-1">No active variants found.</div>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</EntityPickerModal>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useCallback, useEffect } from "react";
|
|
4
|
+
import { Button, Input, Modal, ModalFooter } from "@apptimate/ui";
|
|
5
|
+
import { Plus, Folder } from "lucide-react";
|
|
6
|
+
import {
|
|
7
|
+
EntityPickerModal,
|
|
8
|
+
PickerItem,
|
|
9
|
+
PickerTrigger,
|
|
10
|
+
} from "./EntityPickerModal";
|
|
11
|
+
import { getUomGroups, createUomGroup } from "@apptimate/core-lib";
|
|
12
|
+
import toast from "react-hot-toast";
|
|
13
|
+
|
|
14
|
+
interface UomGroupPickerProps {
|
|
15
|
+
value?: number | string | null;
|
|
16
|
+
displayValue?: string | null;
|
|
17
|
+
onChange: (group: { id: number; name: string; code?: string } | null) => void;
|
|
18
|
+
label?: string;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
isRequired?: boolean;
|
|
21
|
+
pickerZIndex?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function UomGroupPicker({
|
|
25
|
+
value,
|
|
26
|
+
displayValue,
|
|
27
|
+
onChange,
|
|
28
|
+
label = "UoM Group",
|
|
29
|
+
placeholder = "Select Group…",
|
|
30
|
+
isRequired = false,
|
|
31
|
+
pickerZIndex,
|
|
32
|
+
}: UomGroupPickerProps) {
|
|
33
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
34
|
+
const [search, setSearch] = useState("");
|
|
35
|
+
const [groups, setGroups] = useState<any[]>([]);
|
|
36
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
37
|
+
|
|
38
|
+
// Quick add
|
|
39
|
+
const [isQuickAddOpen, setIsQuickAddOpen] = useState(false);
|
|
40
|
+
const [quickAddData, setQuickAddData] = useState({ name: "", code: "" });
|
|
41
|
+
const [isCreating, setIsCreating] = useState(false);
|
|
42
|
+
|
|
43
|
+
const fetchData = useCallback(async () => {
|
|
44
|
+
setIsLoading(true);
|
|
45
|
+
try {
|
|
46
|
+
const res = await getUomGroups();
|
|
47
|
+
if (res.is_success) setGroups(res.result || []);
|
|
48
|
+
} catch {}
|
|
49
|
+
setIsLoading(false);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const handleOpen = () => {
|
|
53
|
+
setSearch("");
|
|
54
|
+
setIsOpen(true);
|
|
55
|
+
fetchData();
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleSelect = (group: any) => {
|
|
59
|
+
onChange({ id: group.id, name: group.name, code: group.code });
|
|
60
|
+
setIsOpen(false);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const handleClear = () => {
|
|
64
|
+
onChange(null);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleQuickAdd = async () => {
|
|
68
|
+
if (!quickAddData.name.trim()) return;
|
|
69
|
+
setIsCreating(true);
|
|
70
|
+
try {
|
|
71
|
+
const res = await createUomGroup({
|
|
72
|
+
name: quickAddData.name.trim(),
|
|
73
|
+
code: quickAddData.code.trim() || undefined,
|
|
74
|
+
});
|
|
75
|
+
if (res.is_success && res.result) {
|
|
76
|
+
toast.success("UoM Group created");
|
|
77
|
+
onChange({ id: res.result.id, name: res.result.name, code: res.result.code });
|
|
78
|
+
setIsQuickAddOpen(false);
|
|
79
|
+
setQuickAddData({ name: "", code: "" });
|
|
80
|
+
setIsOpen(false);
|
|
81
|
+
} else {
|
|
82
|
+
toast.error(res.message || "Failed to create");
|
|
83
|
+
}
|
|
84
|
+
} catch (e: any) {
|
|
85
|
+
toast.error(e.message || "Error creating UoM Group");
|
|
86
|
+
}
|
|
87
|
+
setIsCreating(false);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const searchLower = search.toLowerCase();
|
|
91
|
+
const filtered = groups.filter(
|
|
92
|
+
(g) =>
|
|
93
|
+
g.name.toLowerCase().includes(searchLower) ||
|
|
94
|
+
(g.code && g.code.toLowerCase().includes(searchLower))
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<>
|
|
99
|
+
<PickerTrigger
|
|
100
|
+
label={label}
|
|
101
|
+
value={displayValue || null}
|
|
102
|
+
placeholder={placeholder}
|
|
103
|
+
isRequired={isRequired}
|
|
104
|
+
onClick={handleOpen}
|
|
105
|
+
onClear={value ? handleClear : undefined}
|
|
106
|
+
/>
|
|
107
|
+
|
|
108
|
+
<EntityPickerModal
|
|
109
|
+
isOpen={isOpen}
|
|
110
|
+
onClose={() => setIsOpen(false)}
|
|
111
|
+
onSelect={handleSelect}
|
|
112
|
+
title="Select UoM Group"
|
|
113
|
+
searchPlaceholder="Search Groups…"
|
|
114
|
+
selectedId={value}
|
|
115
|
+
search={search}
|
|
116
|
+
onSearchChange={setSearch}
|
|
117
|
+
size="sm"
|
|
118
|
+
zIndex={pickerZIndex}
|
|
119
|
+
headerActions={
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
onClick={() => { setIsQuickAddOpen(true); setQuickAddData({ name: "", code: "" }); }}
|
|
123
|
+
className="p-1.5 rounded-lg bg-primary-50 text-primary-600 hover:bg-primary-100 transition-colors flex-shrink-0"
|
|
124
|
+
title="Quick Add Group"
|
|
125
|
+
>
|
|
126
|
+
<Plus size={16} strokeWidth={2.5} />
|
|
127
|
+
</button>
|
|
128
|
+
}
|
|
129
|
+
>
|
|
130
|
+
{isLoading ? (
|
|
131
|
+
<div className="py-12 text-center">
|
|
132
|
+
<div className="inline-block h-6 w-6 rounded-full border-2 border-gray-200 border-t-primary-500 animate-spin" />
|
|
133
|
+
<p className="mt-3 text-sm text-gray-400">Loading Groups…</p>
|
|
134
|
+
</div>
|
|
135
|
+
) : filtered.length === 0 ? (
|
|
136
|
+
<div className="py-12 text-center">
|
|
137
|
+
<Folder size={32} className="mx-auto text-gray-300 mb-2" />
|
|
138
|
+
<p className="text-sm text-gray-400">No groups found</p>
|
|
139
|
+
</div>
|
|
140
|
+
) : (
|
|
141
|
+
<div className="space-y-0.5 py-1">
|
|
142
|
+
{filtered.map((group) => (
|
|
143
|
+
<PickerItem
|
|
144
|
+
key={group.id}
|
|
145
|
+
label={group.name}
|
|
146
|
+
sublabel={group.code}
|
|
147
|
+
isSelected={String(value) === String(group.id)}
|
|
148
|
+
onClick={() => handleSelect(group)}
|
|
149
|
+
/>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</EntityPickerModal>
|
|
154
|
+
|
|
155
|
+
<Modal
|
|
156
|
+
isOpen={isQuickAddOpen}
|
|
157
|
+
onClose={() => setIsQuickAddOpen(false)}
|
|
158
|
+
title="Quick Add UoM Group"
|
|
159
|
+
size="sm"
|
|
160
|
+
zIndex={(pickerZIndex || 200) + 100}
|
|
161
|
+
>
|
|
162
|
+
<div className="space-y-4">
|
|
163
|
+
<Input
|
|
164
|
+
label="Name"
|
|
165
|
+
isRequired
|
|
166
|
+
placeholder="e.g. Weight"
|
|
167
|
+
value={quickAddData.name}
|
|
168
|
+
onChange={(e) => setQuickAddData({ ...quickAddData, name: e.target.value })}
|
|
169
|
+
/>
|
|
170
|
+
<Input
|
|
171
|
+
label="Code"
|
|
172
|
+
placeholder="e.g. WGT"
|
|
173
|
+
value={quickAddData.code}
|
|
174
|
+
onChange={(e) => setQuickAddData({ ...quickAddData, code: e.target.value })}
|
|
175
|
+
onKeyDown={(e) => { if (e.key === "Enter") handleQuickAdd(); }}
|
|
176
|
+
/>
|
|
177
|
+
<ModalFooter>
|
|
178
|
+
<Button
|
|
179
|
+
variant="flat"
|
|
180
|
+
color="default"
|
|
181
|
+
onClick={() => setIsQuickAddOpen(false)}
|
|
182
|
+
>
|
|
183
|
+
Cancel
|
|
184
|
+
</Button>
|
|
185
|
+
<Button
|
|
186
|
+
onClick={handleQuickAdd}
|
|
187
|
+
isDisabled={isCreating || !quickAddData.name.trim()}
|
|
188
|
+
>
|
|
189
|
+
{isCreating ? "Creating…" : "Create & Select"}
|
|
190
|
+
</Button>
|
|
191
|
+
</ModalFooter>
|
|
192
|
+
</div>
|
|
193
|
+
</Modal>
|
|
194
|
+
</>
|
|
195
|
+
);
|
|
196
|
+
}
|