@apptimate/ui 5.5.0 → 5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apptimate/ui",
3
- "version": "5.5.0",
3
+ "version": "5.7.0",
4
4
  "main": "src/index.tsx",
5
5
  "types": "src/index.tsx",
6
6
  "dependencies": {
@@ -63,6 +63,9 @@ export interface ButtonProps {
63
63
 
64
64
  /** Test ID for testing */
65
65
  testId?: string;
66
+
67
+ /** Tooltip text on hover */
68
+ title?: string;
66
69
  }
67
70
 
68
71
  const iconSizeMap = {
@@ -219,6 +222,7 @@ export const Button = ({
219
222
  prefix = 'aceui',
220
223
  testId,
221
224
  disableWave = false,
225
+ title,
222
226
  }: ButtonProps) => {
223
227
  const isIconOnly = Boolean(!children && icon);
224
228
  const isActuallyDisabled = isDisabled || isLoading;
@@ -334,6 +338,7 @@ export const Button = ({
334
338
  aria-label={ariaLabel}
335
339
  aria-busy={isLoading ? 'true' : undefined}
336
340
  data-testid={testId}
341
+ title={title}
337
342
  >
338
343
  {wave && (
339
344
  <motion.span
@@ -33,11 +33,13 @@ export const DesktopFilterPopover = ({
33
33
  <div className="relative">
34
34
  <Button
35
35
  variant="soft"
36
- color="default"
37
- className={cn("!p-0 text-gray-500", triggerClassName)}
36
+ color={hasActiveFilter ? 'primary' : 'default'}
37
+ className={cn(triggerClassName)}
38
38
  icon={<SlidersHorizontal size={18} strokeWidth={2} />}
39
39
  ariaLabel={label}
40
- />
40
+ >
41
+ {label}
42
+ </Button>
41
43
  {hasActiveFilter && (
42
44
  <span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-primary-500 rounded-full ring-2 ring-white" />
43
45
  )}
@@ -73,7 +73,9 @@ export function DashboardLayout({
73
73
 
74
74
  // Find which main menu should be active based on current path.
75
75
  // When basePath is set, prefer the menu whose items are mostly within basePath.
76
- const fullPath = basePath + (pathname === "/" && basePath ? "" : pathname);
76
+ const fullPath = (basePath && pathname.startsWith(basePath))
77
+ ? pathname
78
+ : basePath + (pathname === "/" && basePath ? "" : pathname);
77
79
  const currentMainMenu = (() => {
78
80
  let bestMenu = menus[0];
79
81
  let bestScore = -1;
@@ -211,15 +213,18 @@ export function DashboardLayout({
211
213
  setIsSubSidebarOpen(true);
212
214
  }
213
215
  }}
214
- className={`flex flex-col items-center justify-center gap-1.5 cursor-pointer transition-colors w-full px-1 ${isActive ? "text-[#2D3142]" : "text-gray-400 hover:text-[#2D3142]"
216
+ className={`flex flex-col items-center justify-center gap-1.5 cursor-pointer transition-all duration-200 w-full px-1 py-1 relative ${isActive ? "text-indigo-600" : "text-gray-400 hover:text-[#2D3142]"
215
217
  }`}
216
218
  >
219
+ {isActive && (
220
+ <span className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-indigo-600 rounded-r-full" />
221
+ )}
217
222
  {/* Clone the icon to dynamically apply styling based on active state */}
218
223
  {React.cloneElement(menu.icon as React.ReactElement<any>, {
219
224
  size: 22,
220
225
  className: isActive ? "stroke-[2.5]" : "stroke-[2]"
221
226
  })}
222
- <span className={`text-[11px] text-center leading-tight truncate block w-full max-w-[70px] ${isActive ? "font-bold" : "font-medium"}`}>{menu.label}</span>
227
+ <span className={`text-[11px] text-center leading-tight truncate block w-full max-w-[70px] ${isActive ? "font-bold text-indigo-700" : "font-medium"}`}>{menu.label}</span>
223
228
  </div>
224
229
  );
225
230
  })}
@@ -286,9 +291,9 @@ export function DashboardLayout({
286
291
  : !externalPaths.some(ext => item.path.startsWith(ext));
287
292
  const href = basePath && isInternal ? item.path.replace(basePath, "") || "/" : item.path;
288
293
 
289
- const className = `px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between ${isItemActive
290
- ? "bg-[#F4F5F7] text-[#2D3142] font-semibold"
291
- : "text-gray-500 font-medium hover:bg-gray-50"
294
+ const className = `pl-3 pr-4 py-2 rounded-r-lg rounded-l-none text-sm transition-all duration-200 flex items-center justify-between border-l-4 ${isItemActive
295
+ ? "bg-indigo-50/70 border-indigo-600 text-indigo-950 font-bold"
296
+ : "border-transparent text-gray-500 font-medium hover:bg-gray-50/50"
292
297
  }`;
293
298
 
294
299
  const content = (
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
 
3
- import React, { useState, useCallback, useEffect } from "react";
3
+ import React, { useState, useCallback, useEffect, useRef } from "react";
4
4
  import { Button } from '../../base-components/Button';
5
5
  import { Input } from '../../base-components/Input';
6
6
  import { Modal } from '../../base-components/Modal';
7
7
  import { ModalFooter } from '../../base-components/Modal';
8
+ import { AsyncSearchableSelect } from '../../base-components/SearchableSelect';
8
9
 
9
10
  import { Plus, User } from "lucide-react";
10
11
  import {
@@ -12,7 +13,7 @@ import {
12
13
  PickerItem,
13
14
  PickerTrigger,
14
15
  } from "./EntityPickerModal";
15
- import { lookupParties, createParty } from "@apptimate/core-lib";
16
+ import { lookupParties, createParty, sendRequest } from "@apptimate/core-lib";
16
17
  import toast from "react-hot-toast";
17
18
 
18
19
  interface PartyPickerProps {
@@ -49,8 +50,48 @@ export function PartyPicker({
49
50
  const [quickAddLastName, setQuickAddLastName] = useState("");
50
51
  const [quickAddEmail, setQuickAddEmail] = useState("");
51
52
  const [quickAddPhone, setQuickAddPhone] = useState("");
53
+ const [quickAddSecondaryContact, setQuickAddSecondaryContact] = useState("");
54
+ const [quickAddGender, setQuickAddGender] = useState("");
55
+ const [quickAddDOB, setQuickAddDOB] = useState("");
56
+ const [quickAddCurrencyId, setQuickAddCurrencyId] = useState<string | number | undefined>(undefined);
57
+ const [quickAddCurrencyDisplay, setQuickAddCurrencyDisplay] = useState<any>(null);
58
+ const [errors, setErrors] = useState<Record<string, string[]>>({});
52
59
  const [isCreating, setIsCreating] = useState(false);
53
60
 
61
+ const hasInitializedQuickAddCurrency = useRef(false);
62
+
63
+ useEffect(() => {
64
+ if (isQuickAddOpen && !hasInitializedQuickAddCurrency.current) {
65
+ hasInitializedQuickAddCurrency.current = true;
66
+ sendRequest({ url: `${process.env.NEXT_PUBLIC_API_URL}/api/settings`, method: 'GET' })
67
+ .then((res: any) => {
68
+ if (res.responseData?.is_success && res.responseData?.result) {
69
+ const configs = Array.isArray(res.responseData.result) ? res.responseData.result : res.responseData.result.data;
70
+ const baseConfig = configs?.find((c: any) => c.key === 'base_currency');
71
+ if (baseConfig?.resolved_item) {
72
+ const currency = baseConfig.resolved_item;
73
+ setQuickAddCurrencyId(currency.id);
74
+ setQuickAddCurrencyDisplay({ id: currency.id, code: currency.code, displayLabel: `${currency.code} — ${currency.name}` });
75
+ } else if (baseConfig?.value) {
76
+ // Fallback just in case
77
+ const baseId = baseConfig.value;
78
+ sendRequest({ url: `${process.env.NEXT_PUBLIC_API_URL}/api/currencies`, method: 'GET', params: { search: '' } })
79
+ .then((cRes: any) => {
80
+ if (cRes.responseData?.is_success && cRes.responseData?.result?.data) {
81
+ const cData = Array.isArray(cRes.responseData.result.data) ? cRes.responseData.result.data : cRes.responseData.result;
82
+ const baseCurrency = cData.find((c: any) => String(c.id) === String(baseId));
83
+ if (baseCurrency) {
84
+ setQuickAddCurrencyId(baseCurrency.id);
85
+ setQuickAddCurrencyDisplay({ id: baseCurrency.id, code: baseCurrency.code, displayLabel: `${baseCurrency.code} — ${baseCurrency.name}` });
86
+ }
87
+ }
88
+ });
89
+ }
90
+ }
91
+ });
92
+ }
93
+ }, [isQuickAddOpen]);
94
+
54
95
  const fetchData = useCallback(async (query: string = "") => {
55
96
  setIsLoading(true);
56
97
  try {
@@ -90,12 +131,17 @@ export function PartyPicker({
90
131
  const handleQuickAdd = async () => {
91
132
  if (!quickAddFirstName.trim()) return;
92
133
  setIsCreating(true);
134
+ setErrors({});
93
135
  try {
94
136
  const payload = {
95
137
  first_name: quickAddFirstName.trim(),
96
138
  last_name: quickAddLastName.trim() || undefined,
97
139
  email: quickAddEmail.trim() || undefined,
98
140
  primary_contact: quickAddPhone.trim() || undefined,
141
+ secondary_contact: quickAddSecondaryContact.trim() || undefined,
142
+ gender: quickAddGender || undefined,
143
+ date_of_birth: quickAddDOB || undefined,
144
+ default_currency_id: quickAddCurrencyId || undefined,
99
145
  type: [partyType === "all" ? "customer" : partyType],
100
146
  status: "active"
101
147
  };
@@ -110,7 +156,13 @@ export function PartyPicker({
110
156
  setQuickAddLastName("");
111
157
  setQuickAddEmail("");
112
158
  setQuickAddPhone("");
159
+ setQuickAddSecondaryContact("");
160
+ setQuickAddGender("");
161
+ setQuickAddDOB("");
162
+ setErrors({});
113
163
  setIsOpen(false);
164
+ } else if ((res as any).errors) {
165
+ setErrors((res as any).errors);
114
166
  } else {
115
167
  toast.error(res.message || "Failed to create");
116
168
  }
@@ -157,6 +209,10 @@ export function PartyPicker({
157
209
  setQuickAddLastName("");
158
210
  setQuickAddEmail("");
159
211
  setQuickAddPhone("");
212
+ setQuickAddSecondaryContact("");
213
+ setQuickAddGender("");
214
+ setQuickAddDOB("");
215
+ setErrors({});
160
216
  }}
161
217
  className="p-1.5 rounded-lg bg-primary-50 text-primary-600 hover:bg-primary-100 transition-colors flex-shrink-0"
162
218
  title={`Quick Add ${label}`}
@@ -207,12 +263,14 @@ export function PartyPicker({
207
263
  placeholder={`Enter ${label.toLowerCase()} first name`}
208
264
  value={quickAddFirstName}
209
265
  onChange={(e) => setQuickAddFirstName(e.target.value)}
266
+ error={errors.first_name?.[0]}
210
267
  />
211
268
  <Input
212
269
  label="Last Name"
213
270
  placeholder={`Enter ${label.toLowerCase()} last name`}
214
271
  value={quickAddLastName}
215
272
  onChange={(e) => setQuickAddLastName(e.target.value)}
273
+ error={errors.last_name?.[0]}
216
274
  />
217
275
  </div>
218
276
  <div className="grid grid-cols-2 gap-3">
@@ -222,14 +280,76 @@ export function PartyPicker({
222
280
  placeholder="Email address"
223
281
  value={quickAddEmail}
224
282
  onChange={(e) => setQuickAddEmail(e.target.value)}
283
+ error={errors.email?.[0]}
225
284
  />
226
285
  <Input
227
- label="Phone"
228
- placeholder="Phone number"
286
+ label="Primary Contact"
287
+ placeholder="Primary phone number"
229
288
  value={quickAddPhone}
230
289
  onChange={(e) => setQuickAddPhone(e.target.value)}
290
+ error={errors.primary_contact?.[0]}
231
291
  />
232
292
  </div>
293
+ <div className="grid grid-cols-2 gap-3">
294
+ <Input
295
+ label="Secondary Contact"
296
+ placeholder="Alternative phone (optional)"
297
+ value={quickAddSecondaryContact}
298
+ onChange={(e) => setQuickAddSecondaryContact(e.target.value)}
299
+ error={errors.secondary_contact?.[0]}
300
+ />
301
+ <Input
302
+ label="Date of Birth"
303
+ type="date"
304
+ value={quickAddDOB}
305
+ onChange={(e) => setQuickAddDOB(e.target.value)}
306
+ error={errors.date_of_birth?.[0]}
307
+ />
308
+ </div>
309
+ <div className="grid grid-cols-2 gap-3">
310
+ <div className="space-y-1">
311
+ <label className="text-sm font-semibold text-gray-700">Gender</label>
312
+ <select
313
+ value={quickAddGender}
314
+ onChange={(e) => setQuickAddGender(e.target.value)}
315
+ className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white"
316
+ >
317
+ <option value="">Select gender</option>
318
+ <option value="male">Male</option>
319
+ <option value="female">Female</option>
320
+ <option value="other">Other</option>
321
+ <option value="prefer_not_to_say">Prefer not to say</option>
322
+ </select>
323
+ {errors.gender?.[0] && <p className="text-xs text-red-500">{errors.gender[0]}</p>}
324
+ </div>
325
+ {(partyType === "all" || partyType === "customer" || partyType === "supplier") && (
326
+ <AsyncSearchableSelect
327
+ label="Default Currency"
328
+ placeholder="Search currency..."
329
+ loadOptions={async (search, page) => {
330
+ try {
331
+ const res: any = await sendRequest({
332
+ url: `${process.env.NEXT_PUBLIC_API_URL}/api/currencies`,
333
+ method: 'GET',
334
+ params: { search, page, per_page: 20 }
335
+ });
336
+ if (res.responseData?.is_success && res.responseData?.result?.data) {
337
+ return res.responseData.result.data.map((c: any) => ({
338
+ ...c,
339
+ displayLabel: `${c.code} — ${c.name}`
340
+ }));
341
+ }
342
+ return [];
343
+ } catch { return []; }
344
+ }}
345
+ option={{ label: "displayLabel", value: "id", keysToSearch: ["name", "code"] }}
346
+ onChange={(val) => setQuickAddCurrencyId(val as string | number)}
347
+ key={quickAddCurrencyDisplay ? quickAddCurrencyDisplay.code : 'empty'}
348
+ defaultValue={quickAddCurrencyDisplay || undefined}
349
+ error={errors.default_currency_id?.[0]}
350
+ />
351
+ )}
352
+ </div>
233
353
  <ModalFooter>
234
354
  <Button
235
355
  variant="flat"