@apptimate/ui 7.2.0 → 7.3.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.
@@ -0,0 +1,414 @@
1
+ "use client";
2
+
3
+ import React, { useState, useRef, useEffect } from "react";
4
+ import { Modal, ModalFooter } from "../../base-components/Modal";
5
+ import { Input } from "../../base-components/Input";
6
+ import { Button } from "../../base-components/Button";
7
+ import { AsyncSearchableSelect } from "../../base-components/SearchableSelect";
8
+ import { createParty, sendRequest } from "@apptimate/core-lib";
9
+ import toast from "react-hot-toast";
10
+
11
+ interface QuickPartyAddModalProps {
12
+ isOpen: boolean;
13
+ onClose: () => void;
14
+ onSuccess?: (party: any) => void;
15
+ partyType?: "customer" | "supplier" | "all" | "artisan" | "other" | "employee" | "salesperson";
16
+ createAsType?: "customer" | "supplier" | "artisan" | "other" | "employee" | "salesperson";
17
+ label?: string;
18
+ }
19
+
20
+ export function QuickPartyAddModal({
21
+ isOpen,
22
+ onClose,
23
+ onSuccess,
24
+ partyType = "all",
25
+ createAsType,
26
+ label = "Party"
27
+ }: QuickPartyAddModalProps) {
28
+ const [firstName, setFirstName] = useState("");
29
+ const [lastName, setLastName] = useState("");
30
+ const [email, setEmail] = useState("");
31
+ const [phone, setPhone] = useState("");
32
+ const [secondaryContact, setSecondaryContact] = useState("");
33
+ const [dob, setDob] = useState("");
34
+ const [identityType, setIdentityType] = useState("");
35
+ const [identity, setIdentity] = useState("");
36
+
37
+ const [addressLine1, setAddressLine1] = useState("");
38
+ const [addressLine2, setAddressLine2] = useState("");
39
+ const [city, setCity] = useState("");
40
+ const [state, setState] = useState("");
41
+ const [postalCode, setPostalCode] = useState("");
42
+ const [country, setCountry] = useState("");
43
+ const [showDetailedAddress, setShowDetailedAddress] = useState(false);
44
+
45
+ const [currencyId, setCurrencyId] = useState<string | number | undefined>(undefined);
46
+ const [currencyDisplay, setCurrencyDisplay] = useState<any>(null);
47
+ const [errors, setErrors] = useState<Record<string, string[]>>({});
48
+ const [isCreating, setIsCreating] = useState(false);
49
+
50
+ const [fieldConfigs, setFieldConfigs] = useState<any[]>([]);
51
+
52
+ const effectiveType = createAsType || (partyType === "all" ? "customer" : partyType);
53
+
54
+ const mergedConfig = React.useMemo(() => {
55
+ const configMap: Record<string, { active: boolean; mandatory: boolean }> = {};
56
+ const relevantConfigs = fieldConfigs.filter(c => c.entity === effectiveType);
57
+ for (const config of relevantConfigs) {
58
+ if (!configMap[config.field]) {
59
+ configMap[config.field] = { active: false, mandatory: false };
60
+ }
61
+ if (config.status === "active") configMap[config.field].active = true;
62
+ if (config.is_mandatory) configMap[config.field].mandatory = true;
63
+ }
64
+ return configMap;
65
+ }, [fieldConfigs, effectiveType]);
66
+
67
+ const hasInitializedCurrency = useRef(false);
68
+
69
+ useEffect(() => {
70
+ if (isOpen) {
71
+ if (!hasInitializedCurrency.current) {
72
+ hasInitializedCurrency.current = true;
73
+ sendRequest({ url: `${process.env.NEXT_PUBLIC_API_URL}/api/settings`, method: 'GET' })
74
+ .then((res: any) => {
75
+ if (res.responseData?.is_success && res.responseData?.result) {
76
+ const configs = Array.isArray(res.responseData.result) ? res.responseData.result : res.responseData.result.data;
77
+ const baseConfig = configs?.find((c: any) => c.key === 'base_currency');
78
+ if (baseConfig?.resolved_item) {
79
+ const currency = baseConfig.resolved_item;
80
+ setCurrencyId(currency.id);
81
+ setCurrencyDisplay({ id: currency.id, code: currency.code, displayLabel: `${currency.code} — ${currency.name}` });
82
+ } else if (baseConfig?.value) {
83
+ const baseId = baseConfig.value;
84
+ sendRequest({ url: `${process.env.NEXT_PUBLIC_API_URL}/api/currencies`, method: 'GET', params: { search: '' } })
85
+ .then((cRes: any) => {
86
+ if (cRes.responseData?.is_success && cRes.responseData?.result?.data) {
87
+ const cData = Array.isArray(cRes.responseData.result.data) ? cRes.responseData.result.data : cRes.responseData.result;
88
+ const baseCurrency = cData.find((c: any) => String(c.id) === String(baseId));
89
+ if (baseCurrency) {
90
+ setCurrencyId(baseCurrency.id);
91
+ setCurrencyDisplay({
92
+ id: baseCurrency.id,
93
+ code: baseCurrency.code,
94
+ name: baseCurrency.name || "",
95
+ symbol: baseCurrency.symbol || "",
96
+ displayLabel: `${baseCurrency.code}${baseCurrency.name ? ` — ${baseCurrency.name}` : ''}`
97
+ });
98
+ }
99
+ }
100
+ });
101
+ }
102
+ }
103
+ });
104
+ }
105
+
106
+ if (fieldConfigs.length === 0) {
107
+ sendRequest({ url: `${process.env.NEXT_PUBLIC_API_URL}/api/field-configs`, method: 'GET' })
108
+ .then((res: any) => {
109
+ if (res.responseData?.is_success && res.responseData?.result) {
110
+ setFieldConfigs(res.responseData.result);
111
+ }
112
+ });
113
+ }
114
+ }
115
+ }, [isOpen, fieldConfigs.length]);
116
+
117
+ const handleQuickAdd = async () => {
118
+ if (!firstName.trim()) return;
119
+ setIsCreating(true);
120
+ setErrors({});
121
+ try {
122
+ const payload = {
123
+ first_name: firstName.trim(),
124
+ last_name: lastName.trim() || undefined,
125
+ email: email.trim() || undefined,
126
+ primary_contact: phone.trim() || undefined,
127
+ secondary_contact: secondaryContact.trim() || undefined,
128
+ date_of_birth: dob || undefined,
129
+ identity_type: identityType || undefined,
130
+ identity: identity.trim() || undefined,
131
+ default_currency_id: currencyId || undefined,
132
+ type: [createAsType || (partyType === "all" || !partyType ? "customer" : partyType)],
133
+ status: "active",
134
+ address: addressLine1.trim() ? {
135
+ address_line_1: addressLine1.trim(),
136
+ address_line_2: addressLine2.trim() || undefined,
137
+ city: city.trim() || undefined,
138
+ state: state.trim() || undefined,
139
+ postal_code: postalCode.trim() || undefined,
140
+ country: country.trim() || undefined,
141
+ } : undefined
142
+ };
143
+
144
+ const res = await createParty(payload);
145
+ if (res.is_success && res.result) {
146
+ toast.success("Party created");
147
+ const party = res.result;
148
+
149
+ // Reset form
150
+ setFirstName("");
151
+ setLastName("");
152
+ setEmail("");
153
+ setPhone("");
154
+ setSecondaryContact("");
155
+ setDob("");
156
+ setIdentityType("");
157
+ setIdentity("");
158
+ setAddressLine1("");
159
+ setAddressLine2("");
160
+ setCity("");
161
+ setState("");
162
+ setPostalCode("");
163
+ setCountry("");
164
+ setShowDetailedAddress(false);
165
+ setErrors({});
166
+
167
+ if (onSuccess) {
168
+ onSuccess({ ...party, currencyDisplay });
169
+ }
170
+
171
+ onClose();
172
+ } else if ((res as any).errors) {
173
+ setErrors((res as any).errors);
174
+ } else {
175
+ toast.error(res.message || "Failed to create");
176
+ }
177
+ } catch (e: any) {
178
+ toast.error(e.message || "Error creating party");
179
+ }
180
+ setIsCreating(false);
181
+ };
182
+
183
+ return (
184
+ <Modal
185
+ isOpen={isOpen}
186
+ onClose={onClose}
187
+ title={`Quick Add ${label}`}
188
+ size="md"
189
+ zIndex={300}
190
+ backdrop="blur"
191
+ >
192
+ <div className="space-y-4">
193
+ <div className="grid grid-cols-2 gap-3">
194
+ {mergedConfig.first_name?.active !== false && (
195
+ <Input
196
+ label="First Name"
197
+ isRequired={mergedConfig.first_name?.mandatory ?? true}
198
+ placeholder={`Enter ${label.toLowerCase()} first name`}
199
+ value={firstName}
200
+ onChange={(e) => setFirstName(e.target.value)}
201
+ error={errors.first_name?.[0]}
202
+ />
203
+ )}
204
+ {mergedConfig.last_name?.active !== false && (
205
+ <Input
206
+ label="Last Name"
207
+ isRequired={mergedConfig.last_name?.mandatory}
208
+ placeholder={`Enter ${label.toLowerCase()} last name`}
209
+ value={lastName}
210
+ onChange={(e) => setLastName(e.target.value)}
211
+ error={errors.last_name?.[0]}
212
+ />
213
+ )}
214
+ </div>
215
+ <div className="grid grid-cols-2 gap-3">
216
+ {mergedConfig.email?.active !== false && (
217
+ <Input
218
+ label="Email"
219
+ type="email"
220
+ isRequired={mergedConfig.email?.mandatory}
221
+ placeholder="Email address"
222
+ value={email}
223
+ onChange={(e) => setEmail(e.target.value)}
224
+ error={errors.email?.[0]}
225
+ />
226
+ )}
227
+ {mergedConfig.primary_contact?.active !== false && (
228
+ <Input
229
+ label="Primary Contact"
230
+ isRequired={mergedConfig.primary_contact?.mandatory}
231
+ placeholder="Primary phone number"
232
+ value={phone}
233
+ onChange={(e) => setPhone(e.target.value)}
234
+ error={errors.primary_contact?.[0]}
235
+ />
236
+ )}
237
+ </div>
238
+ <div className="grid grid-cols-2 gap-3">
239
+ {mergedConfig.secondary_contact?.active !== false && (
240
+ <Input
241
+ label="Secondary Contact"
242
+ isRequired={mergedConfig.secondary_contact?.mandatory}
243
+ placeholder="Alternative phone (optional)"
244
+ value={secondaryContact}
245
+ onChange={(e) => setSecondaryContact(e.target.value)}
246
+ error={errors.secondary_contact?.[0]}
247
+ />
248
+ )}
249
+ {mergedConfig.date_of_birth?.active !== false && (
250
+ <Input
251
+ label="Date of Birth"
252
+ type="date"
253
+ isRequired={mergedConfig.date_of_birth?.mandatory}
254
+ value={dob}
255
+ onChange={(e) => setDob(e.target.value)}
256
+ error={errors.date_of_birth?.[0]}
257
+ />
258
+ )}
259
+ </div>
260
+ {mergedConfig.identity?.active !== false && (
261
+ <div className="space-y-1.5 w-full">
262
+ <label className="text-[13px] font-medium text-[#4A5568]">
263
+ Identity Details {mergedConfig.identity?.mandatory && <span className="text-red-500">*</span>}
264
+ </label>
265
+ <div className="flex relative">
266
+ <select
267
+ value={identityType}
268
+ onChange={(e) => setIdentityType(e.target.value)}
269
+ className={`w-1/3 bg-surface-0 border-[1.5px] ${errors.identity_type ? 'border-danger-alt' : 'border-border-subtle'} rounded-l-[10px] px-3.5 py-2.5 text-[13.5px] text-foreground-1 outline-none transition-all hover:border-gray-300 focus:border-gray-300 focus:bg-surface-1 border-r-0 focus:z-10 appearance-none`}
270
+ style={{ backgroundImage: 'url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23131313%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right .7rem top 50%', backgroundSize: '.65rem auto' }}
271
+ >
272
+ <option value="">Select type</option>
273
+ <option value="nic">NIC</option>
274
+ <option value="passport">Passport</option>
275
+ <option value="driving_license">Driving License</option>
276
+ <option value="business_registration">Business Registration</option>
277
+ <option value="other">Other</option>
278
+ </select>
279
+ <input
280
+ type="text"
281
+ placeholder="Enter identity number"
282
+ value={identity}
283
+ onChange={(e) => setIdentity(e.target.value)}
284
+ className={`flex-1 bg-surface-0 border-[1.5px] ${errors.identity ? 'border-danger-alt' : 'border-border-subtle'} rounded-r-[10px] px-3.5 py-2.5 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 z-0 focus:z-10 relative`}
285
+ />
286
+ </div>
287
+ {(errors.identity_type?.[0] || errors.identity?.[0]) && (
288
+ <span className="text-[11px] text-danger-alt font-medium">
289
+ {errors.identity_type?.[0] || errors.identity?.[0]}
290
+ </span>
291
+ )}
292
+ </div>
293
+ )}
294
+ {mergedConfig.address?.active !== false && (
295
+ <div className="space-y-3">
296
+ <div className="flex justify-between items-center">
297
+ <label className="text-[13px] font-medium text-[#4A5568]">
298
+ Address {mergedConfig.address?.mandatory ? <span className="text-red-500">*</span> : "(Optional)"}
299
+ </label>
300
+ <button
301
+ type="button"
302
+ onClick={() => setShowDetailedAddress(!showDetailedAddress)}
303
+ className="text-xs font-semibold text-primary-600 hover:text-primary-700 transition-colors"
304
+ >
305
+ {showDetailedAddress ? "Quick Add Address" : "Add Detailed Address"}
306
+ </button>
307
+ </div>
308
+ {!showDetailedAddress ? (
309
+ <Input
310
+ placeholder="Enter complete primary address..."
311
+ value={addressLine1}
312
+ onChange={(e) => setAddressLine1(e.target.value)}
313
+ error={errors.address?.[0] || errors['address.address_line_1']?.[0]}
314
+ />
315
+ ) : (
316
+ <div className="p-3 bg-gray-50 border border-gray-100 rounded-xl space-y-3">
317
+ <Input
318
+ label="Address Line 1"
319
+ placeholder="Street address, P.O. box, etc."
320
+ value={addressLine1}
321
+ onChange={(e) => setAddressLine1(e.target.value)}
322
+ error={errors.address?.[0] || errors['address.address_line_1']?.[0]}
323
+ />
324
+ <Input
325
+ label="Address Line 2"
326
+ placeholder="Apartment, suite, unit, etc. (optional)"
327
+ value={addressLine2}
328
+ onChange={(e) => setAddressLine2(e.target.value)}
329
+ error={errors['address.address_line_2']?.[0]}
330
+ />
331
+ <div className="grid grid-cols-2 gap-3">
332
+ <Input
333
+ label="City"
334
+ placeholder="City"
335
+ value={city}
336
+ onChange={(e) => setCity(e.target.value)}
337
+ error={errors['address.city']?.[0]}
338
+ />
339
+ <Input
340
+ label="State / Province"
341
+ placeholder="State or Province"
342
+ value={state}
343
+ onChange={(e) => setState(e.target.value)}
344
+ error={errors['address.state']?.[0]}
345
+ />
346
+ </div>
347
+ <div className="grid grid-cols-2 gap-3">
348
+ <Input
349
+ label="Postal Code"
350
+ placeholder="ZIP or Postal Code"
351
+ value={postalCode}
352
+ onChange={(e) => setPostalCode(e.target.value)}
353
+ error={errors['address.postal_code']?.[0]}
354
+ />
355
+ <Input
356
+ label="Country"
357
+ placeholder="Country"
358
+ value={country}
359
+ onChange={(e) => setCountry(e.target.value)}
360
+ error={errors['address.country']?.[0]}
361
+ />
362
+ </div>
363
+ </div>
364
+ )}
365
+ </div>
366
+ )}
367
+ <div className="grid grid-cols-2 gap-3">
368
+ {(partyType === "all" || partyType === "customer" || partyType === "supplier" || partyType === "other") && (
369
+ <AsyncSearchableSelect
370
+ label="Default Currency"
371
+ placeholder="Search currency..."
372
+ loadOptions={async (search, page) => {
373
+ try {
374
+ const res: any = await sendRequest({
375
+ url: `${process.env.NEXT_PUBLIC_API_URL}/api/currencies`,
376
+ method: 'GET',
377
+ params: { search, page, per_page: 20 }
378
+ });
379
+ if (res.responseData?.is_success && res.responseData?.result?.data) {
380
+ return res.responseData.result.data.map((c: any) => ({
381
+ ...c,
382
+ displayLabel: `${c.code} — ${c.name}`
383
+ }));
384
+ }
385
+ return [];
386
+ } catch { return []; }
387
+ }}
388
+ option={{ label: "displayLabel", value: "id", keysToSearch: ["name", "code"] }}
389
+ onChange={(val, selectedObj: any) => {
390
+ setCurrencyId(val as string | number);
391
+ setCurrencyDisplay(selectedObj);
392
+ }}
393
+ key={currencyDisplay ? currencyDisplay.code : 'empty'}
394
+ defaultValue={currencyDisplay || undefined}
395
+ error={errors.default_currency_id?.[0]}
396
+ />
397
+ )}
398
+ </div>
399
+ <ModalFooter>
400
+ <Button
401
+ variant="flat"
402
+ color="default"
403
+ onClick={onClose}
404
+ >
405
+ Cancel
406
+ </Button>
407
+ <Button onClick={handleQuickAdd} isDisabled={isCreating || !firstName.trim()}>
408
+ {isCreating ? "Creating…" : "Create & Select"}
409
+ </Button>
410
+ </ModalFooter>
411
+ </div>
412
+ </Modal>
413
+ );
414
+ }
@@ -28,6 +28,7 @@ interface UomPickerProps {
28
28
  uomGroupId?: number | null;
29
29
  pickerZIndex?: number;
30
30
  baseUnitsOnly?: boolean;
31
+ isDisabled?: boolean;
31
32
  }
32
33
 
33
34
  export function UomPicker({
@@ -41,6 +42,7 @@ export function UomPicker({
41
42
  uomGroupId,
42
43
  pickerZIndex,
43
44
  baseUnitsOnly = false,
45
+ isDisabled = false,
44
46
  }: UomPickerProps) {
45
47
  const [isOpen, setIsOpen] = useState(false);
46
48
  const [search, setSearch] = useState("");
@@ -90,9 +92,11 @@ export function UomPicker({
90
92
  }
91
93
  setIsCreating(true);
92
94
  try {
93
- // 1. Create Group
95
+ // 1. Create Group (which automatically creates the base UOM on the backend)
94
96
  const groupRes = await createUomGroup({
95
- name: quickAddData.new_group_name.trim()
97
+ name: quickAddData.new_group_name.trim(),
98
+ base_uom_name: quickAddData.name.trim(),
99
+ base_uom_code: quickAddData.abbreviation.trim()
96
100
  });
97
101
 
98
102
  if (!groupRes.is_success || !groupRes.result) {
@@ -101,26 +105,21 @@ export function UomPicker({
101
105
  return;
102
106
  }
103
107
 
108
+ toast.success("UOM created");
109
+
104
110
  const newGroupId = groupRes.result.id;
105
-
106
- // 2. Create UOM (as base unit for the new group)
107
- const res = await createUom({
108
- name: quickAddData.name.trim(),
109
- abbreviation: quickAddData.abbreviation.trim(),
110
- conversion_factor: 1,
111
- uom_group_id: newGroupId,
112
- base_uom_id: null,
113
- status: quickAddData.status,
111
+ const baseUomId = groupRes.result.base_uom?.id || -1;
112
+
113
+ onChange({
114
+ id: baseUomId,
115
+ name: quickAddData.name.trim(),
116
+ abbreviation: quickAddData.abbreviation.trim(),
117
+ uom_group_id: newGroupId
114
118
  });
115
- if (res.is_success && res.result) {
116
- toast.success("UOM created");
117
- onChange({ id: res.result.id, name: res.result.name, abbreviation: res.result.abbreviation, uom_group_id: res.result.uom_group_id });
118
- setIsQuickAddOpen(false);
119
- setQuickAddData({ name: "", abbreviation: "", new_group_name: "", status: "active" });
120
- setIsOpen(false);
121
- } else {
122
- toast.error(res.message || "Failed to create");
123
- }
119
+
120
+ setIsQuickAddOpen(false);
121
+ setQuickAddData({ name: "", abbreviation: "", new_group_name: "", status: "active" });
122
+ setIsOpen(false);
124
123
  } catch (e: any) {
125
124
  toast.error(e.message || "Error creating UOM");
126
125
  }
@@ -149,6 +148,7 @@ export function UomPicker({
149
148
  value={displayValue || null}
150
149
  placeholder={placeholder}
151
150
  isRequired={isRequired}
151
+ disabled={isDisabled}
152
152
  onClick={handleOpen}
153
153
  onClear={value ? handleClear : undefined}
154
154
  />