@apptimate/ui 3.4.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,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
+ }