@apptimate/ui 7.1.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.
- package/package.json +1 -1
- package/src/base-components/FormattedNumberInput.tsx +118 -0
- package/src/base-components/Select.tsx +1 -1
- package/src/common-components/DashboardLayout.tsx +101 -57
- package/src/common-components/PaymentSection.tsx +200 -155
- package/src/common-components/item-wizard/ItemFormWizard.tsx +118 -60
- package/src/common-components/pickers/PartyPicker.tsx +28 -401
- package/src/common-components/pickers/QuickPartyAddModal.tsx +414 -0
- package/src/common-components/pickers/UomPicker.tsx +20 -20
- package/src/common-components/pickers/modals/BatchSelectionModal.tsx +167 -114
- package/src/common-components/transaction/MetadataPanel.tsx +26 -17
- package/src/common-components/transaction/ProductSelectionPanel.tsx +7 -5
- package/src/common-components/transaction/ProductTransactionScreen.tsx +35 -5
- package/src/common-components/transaction/types.ts +4 -0
- package/src/components/shared/ImageUploadComponent.tsx +3 -0
- package/src/finance-components/DirectTransactionDetailModal.tsx +153 -0
- package/src/index.tsx +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from 'react';
|
|
4
|
+
import { cn } from '@apptimate/core-lib';
|
|
5
|
+
|
|
6
|
+
export interface FormattedNumberInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> {
|
|
7
|
+
value?: number | string | "";
|
|
8
|
+
onChange?: (value: number | "") => void;
|
|
9
|
+
decimals?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const FormattedNumberInput = React.forwardRef<HTMLInputElement, FormattedNumberInputProps>(
|
|
13
|
+
({ value, onChange, decimals = 2, className, ...props }, ref) => {
|
|
14
|
+
const [displayValue, setDisplayValue] = useState("");
|
|
15
|
+
|
|
16
|
+
const formatNumber = (val: number | string | undefined) => {
|
|
17
|
+
if (val === "" || val === null || val === undefined) return "";
|
|
18
|
+
const num = Number(val);
|
|
19
|
+
if (isNaN(num)) return "";
|
|
20
|
+
return num.toLocaleString('en-US', { maximumFractionDigits: decimals });
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (value === "" || value === null || value === undefined) {
|
|
25
|
+
setDisplayValue("");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check if user is typing something that evaluates to the same number (like "20." or "20.0")
|
|
30
|
+
// If they are, don't overwrite displayValue to preserve their cursor/typing state
|
|
31
|
+
const currentRaw = displayValue.replace(/,/g, '');
|
|
32
|
+
if (Number(value) !== Number(currentRaw)) {
|
|
33
|
+
setDisplayValue(formatNumber(value));
|
|
34
|
+
}
|
|
35
|
+
}, [value, decimals, displayValue]);
|
|
36
|
+
|
|
37
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
38
|
+
let val = e.target.value;
|
|
39
|
+
|
|
40
|
+
// Handle empty
|
|
41
|
+
if (val === "") {
|
|
42
|
+
setDisplayValue("");
|
|
43
|
+
onChange?.("");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Remove non-numeric characters except period and minus sign
|
|
48
|
+
const isNegative = val.startsWith('-');
|
|
49
|
+
val = val.replace(/[^0-9.]/g, '');
|
|
50
|
+
|
|
51
|
+
// Prevent multiple periods
|
|
52
|
+
const parts = val.split('.');
|
|
53
|
+
if (parts.length > 2) {
|
|
54
|
+
val = parts[0] + '.' + parts.slice(1).join('');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Limit decimal places
|
|
58
|
+
if (parts.length > 1 && parts[1].length > decimals) {
|
|
59
|
+
val = parts[0] + '.' + parts[1].substring(0, decimals);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (isNegative) val = '-' + val;
|
|
63
|
+
|
|
64
|
+
// Update display value with comma formatting for the integer part
|
|
65
|
+
let formatted = val;
|
|
66
|
+
const currentParts = val.split('.');
|
|
67
|
+
const integerPart = currentParts[0];
|
|
68
|
+
|
|
69
|
+
if (integerPart && integerPart !== '-' && integerPart !== '-0') {
|
|
70
|
+
const numInt = parseInt(integerPart, 10);
|
|
71
|
+
if (!isNaN(numInt)) {
|
|
72
|
+
const formattedInt = numInt.toLocaleString('en-US');
|
|
73
|
+
formatted = (isNegative && numInt > 0) ? '-' + formattedInt : formattedInt;
|
|
74
|
+
// Re-attach decimal part if it exists
|
|
75
|
+
if (currentParts.length > 1) {
|
|
76
|
+
formatted += '.' + currentParts[1];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} else if (integerPart === '-0') {
|
|
80
|
+
formatted = '-0' + (currentParts.length > 1 ? '.' + currentParts[1] : '');
|
|
81
|
+
} else if (integerPart === '-') {
|
|
82
|
+
formatted = '-';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
setDisplayValue(formatted);
|
|
86
|
+
|
|
87
|
+
if (val === "-" || val === "-0") {
|
|
88
|
+
onChange?.("");
|
|
89
|
+
} else {
|
|
90
|
+
const numericValue = parseFloat(val);
|
|
91
|
+
if (!isNaN(numericValue)) {
|
|
92
|
+
onChange?.(numericValue);
|
|
93
|
+
} else if (val === ".") {
|
|
94
|
+
onChange?.("");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
100
|
+
setDisplayValue(formatNumber(value));
|
|
101
|
+
props.onBlur?.(e);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<input
|
|
106
|
+
ref={ref}
|
|
107
|
+
type="text"
|
|
108
|
+
value={displayValue}
|
|
109
|
+
onChange={handleChange}
|
|
110
|
+
onBlur={handleBlur}
|
|
111
|
+
className={cn('outline-none', className)}
|
|
112
|
+
{...props}
|
|
113
|
+
/>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
FormattedNumberInput.displayName = 'FormattedNumberInput';
|
|
@@ -6,7 +6,7 @@ import React from 'react';
|
|
|
6
6
|
import { Label } from './Label';
|
|
7
7
|
|
|
8
8
|
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
|
9
|
-
label?: string;
|
|
9
|
+
label?: React.ReactNode | string;
|
|
10
10
|
error?: string;
|
|
11
11
|
isRequired?: boolean;
|
|
12
12
|
options: { label: string; value: string | number }[];
|
|
@@ -70,6 +70,13 @@ export function DashboardLayout({
|
|
|
70
70
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
71
71
|
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
|
|
72
72
|
const [isSubSidebarOpen, setIsSubSidebarOpen] = useState(true);
|
|
73
|
+
const [isMenusLoading, setIsMenusLoading] = useState(true);
|
|
74
|
+
|
|
75
|
+
React.useEffect(() => {
|
|
76
|
+
if (menus && menus.length > 0) {
|
|
77
|
+
setIsMenusLoading(false);
|
|
78
|
+
}
|
|
79
|
+
}, [menus]);
|
|
73
80
|
const pathname = usePathname() || "";
|
|
74
81
|
const router = useRouter();
|
|
75
82
|
|
|
@@ -247,7 +254,14 @@ export function DashboardLayout({
|
|
|
247
254
|
}
|
|
248
255
|
`}} />
|
|
249
256
|
<nav className="flex flex-col gap-6 flex-1 w-full sidebar-scroll pb-4">
|
|
250
|
-
{
|
|
257
|
+
{isMenusLoading ? (
|
|
258
|
+
Array.from({ length: 6 }).map((_, i) => (
|
|
259
|
+
<div key={`skel-${i}`} className="flex flex-col items-center justify-center gap-2 w-full px-1">
|
|
260
|
+
<div className="w-[22px] h-[22px] bg-gray-200/80 rounded-md animate-pulse" />
|
|
261
|
+
<div className="w-12 h-2.5 bg-gray-200/80 rounded animate-pulse" />
|
|
262
|
+
</div>
|
|
263
|
+
))
|
|
264
|
+
) : effectiveMenus.map((menu) => {
|
|
251
265
|
const isActive = activeMenuId === menu.id;
|
|
252
266
|
return (
|
|
253
267
|
<div
|
|
@@ -318,68 +332,91 @@ export function DashboardLayout({
|
|
|
318
332
|
</aside>
|
|
319
333
|
|
|
320
334
|
{/* Desktop Secondary Sidebar */}
|
|
321
|
-
{activeMenu && activeMenu.groups.length > 0
|
|
335
|
+
{isMenusLoading || (activeMenu && activeMenu.groups.length > 0) ? (
|
|
322
336
|
<>
|
|
323
337
|
<aside className={cn(
|
|
324
338
|
"hidden md:flex print:hidden bg-white border-gray-200 flex-col py-8 shrink-0 z-10 transition-all duration-300 overflow-hidden group",
|
|
325
339
|
isSubSidebarOpen ? "w-[210px]" : "w-0 opacity-0 px-0"
|
|
326
340
|
)}>
|
|
327
|
-
|
|
328
|
-
<
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
</button>
|
|
332
|
-
</div>
|
|
333
|
-
<div className="flex-1 overflow-y-auto px-4 space-y-6">
|
|
334
|
-
{activeMenu.groups.map((group) => (
|
|
335
|
-
<div key={group.id}>
|
|
336
|
-
{group.label && (
|
|
337
|
-
<h3 className="px-2 text-xs font-bold text-gray-400 uppercase tracking-wider mb-2 empty:hidden">{group.label}</h3>
|
|
338
|
-
)}
|
|
339
|
-
<nav className="flex flex-col gap-1">
|
|
340
|
-
{group.items.map((item: any) => {
|
|
341
|
-
// Find the longest matching path in this group to avoid parent paths being active
|
|
342
|
-
const allGroupItems = activeMenu.groups.flatMap(g => g.items);
|
|
343
|
-
const matchingItems = allGroupItems.filter(i => fullPath === i.path || fullPath.startsWith(`${i.path}/`));
|
|
344
|
-
const longestMatch = matchingItems.sort((a, b) => b.path.length - a.path.length)[0];
|
|
345
|
-
const isItemActive = longestMatch?.path === item.path;
|
|
346
|
-
const isInternal = basePath
|
|
347
|
-
? item.path.startsWith(basePath)
|
|
348
|
-
: !externalPaths.some(ext => item.path.startsWith(ext));
|
|
349
|
-
const href = basePath && isInternal ? item.path.replace(basePath, "") || "/" : item.path;
|
|
350
|
-
|
|
351
|
-
const className = `px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between ${isItemActive
|
|
352
|
-
? "bg-[#F4F5F7] text-[#2D3142] font-semibold"
|
|
353
|
-
: "text-gray-500 font-medium hover:bg-gray-50"
|
|
354
|
-
}`;
|
|
355
|
-
|
|
356
|
-
const content = (
|
|
357
|
-
<>
|
|
358
|
-
<div className="flex items-center gap-3">
|
|
359
|
-
{item.icon && <span className={`${isItemActive ? 'text-[#2D3142]' : 'text-gray-400'}`}>{item.icon}</span>}
|
|
360
|
-
<span>{item.label}</span>
|
|
361
|
-
</div>
|
|
362
|
-
{item.badge && <div>{item.badge}</div>}
|
|
363
|
-
</>
|
|
364
|
-
);
|
|
365
|
-
|
|
366
|
-
return isInternal ? (
|
|
367
|
-
<Link key={item.id} href={href} className={className}>
|
|
368
|
-
{content}
|
|
369
|
-
</Link>
|
|
370
|
-
) : (
|
|
371
|
-
<a key={item.id} href={href} className={className}>
|
|
372
|
-
{content}
|
|
373
|
-
</a>
|
|
374
|
-
);
|
|
375
|
-
})}
|
|
376
|
-
</nav>
|
|
341
|
+
{isMenusLoading ? (
|
|
342
|
+
<div className="flex-1 px-4 w-[210px]">
|
|
343
|
+
<div className="flex items-center px-2 mb-8 mt-1">
|
|
344
|
+
<div className="w-24 h-6 bg-gray-200/80 rounded animate-pulse" />
|
|
377
345
|
</div>
|
|
378
|
-
|
|
379
|
-
|
|
346
|
+
<div className="space-y-8">
|
|
347
|
+
{Array.from({ length: 3 }).map((_, i) => (
|
|
348
|
+
<div key={`skel-group-${i}`} className="space-y-3">
|
|
349
|
+
<div className="w-16 h-3 bg-gray-200/80 rounded animate-pulse mx-2 mb-4" />
|
|
350
|
+
{Array.from({ length: 4 }).map((_, j) => (
|
|
351
|
+
<div key={`skel-item-${j}`} className="flex items-center gap-3 px-2 py-2">
|
|
352
|
+
<div className="w-4 h-4 bg-gray-200/80 rounded animate-pulse shrink-0" />
|
|
353
|
+
<div className="w-full h-3 bg-gray-200/80 rounded animate-pulse" />
|
|
354
|
+
</div>
|
|
355
|
+
))}
|
|
356
|
+
</div>
|
|
357
|
+
))}
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
) : (
|
|
361
|
+
<>
|
|
362
|
+
<div className="flex items-center justify-between px-6 mb-6 w-[210px]">
|
|
363
|
+
<h2 className="text-xl font-bold text-[#2D3142]/80 truncate">{activeMenu!.label}</h2>
|
|
364
|
+
<button onClick={() => setIsSubSidebarOpen(false)} className="text-gray-400 hover:text-[#2D3142] transition-colors flex-shrink-0 outline-none opacity-0 group-hover:opacity-100 cursor-pointer">
|
|
365
|
+
<PanelLeftClose size={18} />
|
|
366
|
+
</button>
|
|
367
|
+
</div>
|
|
368
|
+
<div className="flex-1 overflow-y-auto px-4 space-y-6">
|
|
369
|
+
{activeMenu!.groups.map((group) => (
|
|
370
|
+
<div key={group.id}>
|
|
371
|
+
{group.label && (
|
|
372
|
+
<h3 className="px-2 text-xs font-bold text-gray-400 uppercase tracking-wider mb-2 empty:hidden">{group.label}</h3>
|
|
373
|
+
)}
|
|
374
|
+
<nav className="flex flex-col gap-1">
|
|
375
|
+
{group.items.map((item: any) => {
|
|
376
|
+
// Find the longest matching path in this group to avoid parent paths being active
|
|
377
|
+
const allGroupItems = activeMenu!.groups.flatMap(g => g.items);
|
|
378
|
+
const matchingItems = allGroupItems.filter(i => fullPath === i.path || fullPath.startsWith(`${i.path}/`));
|
|
379
|
+
const longestMatch = matchingItems.sort((a, b) => b.path.length - a.path.length)[0];
|
|
380
|
+
const isItemActive = longestMatch?.path === item.path;
|
|
381
|
+
const isInternal = basePath
|
|
382
|
+
? item.path.startsWith(basePath)
|
|
383
|
+
: !externalPaths.some(ext => item.path.startsWith(ext));
|
|
384
|
+
const href = basePath && isInternal ? item.path.replace(basePath, "") || "/" : item.path;
|
|
385
|
+
|
|
386
|
+
const className = `px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between ${isItemActive
|
|
387
|
+
? "bg-[#F4F5F7] text-[#2D3142] font-semibold"
|
|
388
|
+
: "text-gray-500 font-medium hover:bg-gray-50"
|
|
389
|
+
}`;
|
|
390
|
+
|
|
391
|
+
const content = (
|
|
392
|
+
<>
|
|
393
|
+
<div className="flex items-center gap-3">
|
|
394
|
+
{item.icon && <span className={`${isItemActive ? 'text-[#2D3142]' : 'text-gray-400'}`}>{item.icon}</span>}
|
|
395
|
+
<span>{item.label}</span>
|
|
396
|
+
</div>
|
|
397
|
+
{item.badge && <div>{item.badge}</div>}
|
|
398
|
+
</>
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
return isInternal ? (
|
|
402
|
+
<Link key={item.id} href={href} className={className}>
|
|
403
|
+
{content}
|
|
404
|
+
</Link>
|
|
405
|
+
) : (
|
|
406
|
+
<a key={item.id} href={href} className={className}>
|
|
407
|
+
{content}
|
|
408
|
+
</a>
|
|
409
|
+
);
|
|
410
|
+
})}
|
|
411
|
+
</nav>
|
|
412
|
+
</div>
|
|
413
|
+
))}
|
|
414
|
+
</div>
|
|
415
|
+
</>
|
|
416
|
+
)}
|
|
380
417
|
|
|
381
418
|
{/* Organization Selector - Bottom of Secondary Sidebar */}
|
|
382
|
-
{organizations.length > 0 && (
|
|
419
|
+
{organizations.length > 0 && !isMenusLoading && (
|
|
383
420
|
<div className="px-4 pt-4 mt-2 border-t border-gray-100">
|
|
384
421
|
<button
|
|
385
422
|
id="org-selector-desktop"
|
|
@@ -405,7 +442,7 @@ export function DashboardLayout({
|
|
|
405
442
|
</aside>
|
|
406
443
|
|
|
407
444
|
</>
|
|
408
|
-
)}
|
|
445
|
+
) : null}
|
|
409
446
|
|
|
410
447
|
{/* Main Content Area & Mobile Header */}
|
|
411
448
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden print:overflow-visible">
|
|
@@ -481,7 +518,14 @@ export function DashboardLayout({
|
|
|
481
518
|
|
|
482
519
|
<div className="flex-1 overflow-y-auto py-6 px-4">
|
|
483
520
|
<nav className="flex flex-col gap-8">
|
|
484
|
-
{
|
|
521
|
+
{isMenusLoading ? (
|
|
522
|
+
Array.from({ length: 4 }).map((_, i) => (
|
|
523
|
+
<div key={`skel-mob-${i}`} className="flex items-center gap-3 mb-3 px-2">
|
|
524
|
+
<div className="w-5 h-5 bg-gray-200/80 rounded-md animate-pulse" />
|
|
525
|
+
<div className="w-24 h-4 bg-gray-200/80 rounded animate-pulse" />
|
|
526
|
+
</div>
|
|
527
|
+
))
|
|
528
|
+
) : effectiveMenus.map((menu) => (
|
|
485
529
|
<div key={menu.id}>
|
|
486
530
|
<div className="flex items-center gap-3 text-[#2D3142] font-bold mb-3 px-2">
|
|
487
531
|
{React.cloneElement(menu.icon as React.ReactElement<any>, {
|