@apptimate/ui 1.1.0 → 1.2.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 +4 -3
- package/src/base-components/DateLabel.tsx +68 -0
- package/src/base-components/DateRangePicker.tsx +392 -0
- package/src/base-components/ImageDropzone.tsx +80 -0
- package/src/base-components/Modal.tsx +19 -7
- package/src/base-components/PopConfirm.tsx +15 -4
- package/src/common-components/DashboardLayout.tsx +16 -5
- package/src/common-components/charts/RBarChart.tsx +145 -0
- package/src/common-components/charts/RLineChart.tsx +116 -0
- package/src/common-components/charts/index.ts +3 -0
- package/src/common-components/charts/palette.ts +10 -0
- package/src/components/shared/CustomerSelectorComponent.tsx +163 -0
- package/src/components/shared/ImageUploadComponent.tsx +228 -0
- package/src/components/shared/PaymentModeComponent.tsx +421 -0
- package/src/components/shared/ProcurementManagerSelector.tsx +5 -0
- package/src/components/shared/ProductSelectorComponent.tsx +166 -0
- package/src/components/shared/SupplierSelectorComponent.tsx +5 -0
- package/src/index.tsx +6 -1
- /package/src/common-components/{Charts.tsx → charts/ApexCharts.tsx} +0 -0
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apptimate/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"main": "src/index.tsx",
|
|
5
5
|
"types": "src/index.tsx",
|
|
6
6
|
"dependencies": {
|
|
7
|
+
"@apptimate/core-lib": "*",
|
|
7
8
|
"class-variance-authority": "^0.7.1",
|
|
8
9
|
"clsx": "^2.1.1",
|
|
9
10
|
"framer-motion": "^12.38.0",
|
|
10
11
|
"lucide-react": "^1.14.0",
|
|
11
12
|
"react": "^19.2.5",
|
|
12
|
-
"
|
|
13
|
-
"
|
|
13
|
+
"recharts": "^2.15.4",
|
|
14
|
+
"tailwind-merge": "^3.5.0"
|
|
14
15
|
},
|
|
15
16
|
"publishConfig": {
|
|
16
17
|
"access": "public"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface DateLabelProps {
|
|
4
|
+
date: string | Date;
|
|
5
|
+
className?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function DateLabel({ date, className = '' }: DateLabelProps) {
|
|
9
|
+
if (!date) return <span className="text-gray-300">-</span>;
|
|
10
|
+
|
|
11
|
+
const d = new Date(date);
|
|
12
|
+
|
|
13
|
+
// Format to "2026-05-07 4:35PM"
|
|
14
|
+
const year = d.getFullYear();
|
|
15
|
+
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
16
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
17
|
+
let hours = d.getHours();
|
|
18
|
+
const minutes = String(d.getMinutes()).padStart(2, '0');
|
|
19
|
+
const ampm = hours >= 12 ? 'PM' : 'AM';
|
|
20
|
+
hours = hours % 12;
|
|
21
|
+
hours = hours ? hours : 12; // the hour '0' should be '12'
|
|
22
|
+
|
|
23
|
+
const formattedDate = `${year}-${month}-${day} ${hours}:${minutes}${ampm}`;
|
|
24
|
+
|
|
25
|
+
// Calculate relative time
|
|
26
|
+
const now = new Date();
|
|
27
|
+
const diffInSeconds = Math.floor((now.getTime() - d.getTime()) / 1000);
|
|
28
|
+
|
|
29
|
+
let relativeText = null;
|
|
30
|
+
|
|
31
|
+
if (diffInSeconds < 60) {
|
|
32
|
+
relativeText = 'just now';
|
|
33
|
+
} else if (diffInSeconds < 3600) {
|
|
34
|
+
const mins = Math.floor(diffInSeconds / 60);
|
|
35
|
+
relativeText = `${mins} min${mins !== 1 ? 's' : ''} ago`;
|
|
36
|
+
} else if (diffInSeconds < 86400) {
|
|
37
|
+
// Less than 24 hours ago
|
|
38
|
+
const hours = Math.floor(diffInSeconds / 3600);
|
|
39
|
+
|
|
40
|
+
// Check if it's the same calendar day or previous
|
|
41
|
+
const isSameDay = now.getDate() === d.getDate() && now.getMonth() === d.getMonth() && now.getFullYear() === d.getFullYear();
|
|
42
|
+
|
|
43
|
+
if (isSameDay) {
|
|
44
|
+
relativeText = `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
|
45
|
+
} else {
|
|
46
|
+
relativeText = 'yesterday';
|
|
47
|
+
}
|
|
48
|
+
} else if (diffInSeconds < 172800) {
|
|
49
|
+
// Between 24 and 48 hours ago
|
|
50
|
+
const yesterday = new Date(now);
|
|
51
|
+
yesterday.setDate(now.getDate() - 1);
|
|
52
|
+
|
|
53
|
+
const isYesterday = yesterday.getDate() === d.getDate() && yesterday.getMonth() === d.getMonth() && yesterday.getFullYear() === d.getFullYear();
|
|
54
|
+
|
|
55
|
+
if (isYesterday) {
|
|
56
|
+
relativeText = 'yesterday';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className={`flex flex-col ${className}`}>
|
|
62
|
+
<span className="text-gray-600 font-medium whitespace-nowrap">{formattedDate}</span>
|
|
63
|
+
{relativeText && (
|
|
64
|
+
<span className="text-[10.5px] text-gray-400 font-semibold">{relativeText}</span>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { Calendar, ChevronDown, X, Check } from 'lucide-react';
|
|
6
|
+
import { cn } from '@apptimate/core-lib';
|
|
7
|
+
|
|
8
|
+
// ── Preset definitions ──────────────────────────────────────────────
|
|
9
|
+
export type DateRangePresetKey =
|
|
10
|
+
| 'today'
|
|
11
|
+
| 'yesterday'
|
|
12
|
+
| 'this_week'
|
|
13
|
+
| 'last_7_days'
|
|
14
|
+
| 'this_month'
|
|
15
|
+
| 'last_30_days'
|
|
16
|
+
| 'this_year'
|
|
17
|
+
| 'custom';
|
|
18
|
+
|
|
19
|
+
export interface DateRange {
|
|
20
|
+
from: string; // YYYY-MM-DD
|
|
21
|
+
to: string; // YYYY-MM-DD
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DateRangePreset {
|
|
25
|
+
key: DateRangePresetKey;
|
|
26
|
+
label: string;
|
|
27
|
+
resolve: () => DateRange;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const fmt = (d: Date): string => {
|
|
31
|
+
const y = d.getFullYear();
|
|
32
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
33
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
34
|
+
return `${y}-${m}-${day}`;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const today = (): Date => {
|
|
38
|
+
const d = new Date();
|
|
39
|
+
d.setHours(0, 0, 0, 0);
|
|
40
|
+
return d;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const PRESETS: DateRangePreset[] = [
|
|
44
|
+
{
|
|
45
|
+
key: 'today',
|
|
46
|
+
label: 'Today',
|
|
47
|
+
resolve: () => {
|
|
48
|
+
const d = fmt(today());
|
|
49
|
+
return { from: d, to: d };
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
key: 'yesterday',
|
|
54
|
+
label: 'Yesterday',
|
|
55
|
+
resolve: () => {
|
|
56
|
+
const d = today();
|
|
57
|
+
d.setDate(d.getDate() - 1);
|
|
58
|
+
const s = fmt(d);
|
|
59
|
+
return { from: s, to: s };
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
key: 'this_week',
|
|
64
|
+
label: 'This Week',
|
|
65
|
+
resolve: () => {
|
|
66
|
+
const d = today();
|
|
67
|
+
const day = d.getDay(); // 0=Sun
|
|
68
|
+
const diff = day === 0 ? 6 : day - 1; // monday start
|
|
69
|
+
const start = new Date(d);
|
|
70
|
+
start.setDate(d.getDate() - diff);
|
|
71
|
+
return { from: fmt(start), to: fmt(d) };
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
key: 'last_7_days',
|
|
76
|
+
label: 'Last 7 Days',
|
|
77
|
+
resolve: () => {
|
|
78
|
+
const d = today();
|
|
79
|
+
const start = new Date(d);
|
|
80
|
+
start.setDate(d.getDate() - 6);
|
|
81
|
+
return { from: fmt(start), to: fmt(d) };
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
key: 'this_month',
|
|
86
|
+
label: 'This Month',
|
|
87
|
+
resolve: () => {
|
|
88
|
+
const d = today();
|
|
89
|
+
const start = new Date(d.getFullYear(), d.getMonth(), 1);
|
|
90
|
+
return { from: fmt(start), to: fmt(d) };
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
key: 'last_30_days',
|
|
95
|
+
label: 'Last 30 Days',
|
|
96
|
+
resolve: () => {
|
|
97
|
+
const d = today();
|
|
98
|
+
const start = new Date(d);
|
|
99
|
+
start.setDate(d.getDate() - 29);
|
|
100
|
+
return { from: fmt(start), to: fmt(d) };
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
key: 'this_year',
|
|
105
|
+
label: 'This Year',
|
|
106
|
+
resolve: () => {
|
|
107
|
+
const d = today();
|
|
108
|
+
const start = new Date(d.getFullYear(), 0, 1);
|
|
109
|
+
return { from: fmt(start), to: fmt(d) };
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
key: 'custom',
|
|
114
|
+
label: 'Custom Range',
|
|
115
|
+
resolve: () => {
|
|
116
|
+
const d = fmt(today());
|
|
117
|
+
return { from: d, to: d };
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
// ── Props ──────────────────────────────────────────────────────────
|
|
123
|
+
export interface DateRangePickerProps {
|
|
124
|
+
/** Currently selected preset key */
|
|
125
|
+
value: DateRangePresetKey;
|
|
126
|
+
/** The resolved from/to dates */
|
|
127
|
+
dateRange: DateRange;
|
|
128
|
+
/** Callback when selection changes */
|
|
129
|
+
onChange: (preset: DateRangePresetKey, range: DateRange) => void;
|
|
130
|
+
/** Additional className for the trigger */
|
|
131
|
+
className?: string;
|
|
132
|
+
/** Alignment of the dropdown */
|
|
133
|
+
align?: 'left' | 'right';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
137
|
+
const formatDisplayDate = (dateStr: string): string => {
|
|
138
|
+
if (!dateStr) return '';
|
|
139
|
+
const d = new Date(dateStr + 'T00:00:00');
|
|
140
|
+
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const getDisplayLabel = (preset: DateRangePresetKey, range: DateRange): string => {
|
|
144
|
+
const p = PRESETS.find((pr) => pr.key === preset);
|
|
145
|
+
if (!p) return '';
|
|
146
|
+
if (preset === 'custom') {
|
|
147
|
+
if (range.from === range.to) return formatDisplayDate(range.from);
|
|
148
|
+
return `${formatDisplayDate(range.from)} – ${formatDisplayDate(range.to)}`;
|
|
149
|
+
}
|
|
150
|
+
return p.label;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// ── Component ──────────────────────────────────────────────────────
|
|
154
|
+
export const DateRangePicker = ({
|
|
155
|
+
value,
|
|
156
|
+
dateRange,
|
|
157
|
+
onChange,
|
|
158
|
+
className,
|
|
159
|
+
align = 'left',
|
|
160
|
+
}: DateRangePickerProps) => {
|
|
161
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
162
|
+
const [coords, setCoords] = useState({ top: 0, bottom: 0, left: 0, right: 0 });
|
|
163
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
164
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
165
|
+
|
|
166
|
+
// Custom-range local state (only used when editing custom)
|
|
167
|
+
const [customFrom, setCustomFrom] = useState(dateRange.from);
|
|
168
|
+
const [customTo, setCustomTo] = useState(dateRange.to);
|
|
169
|
+
const [showCustomPanel, setShowCustomPanel] = useState(value === 'custom');
|
|
170
|
+
|
|
171
|
+
// Sync custom state when dateRange changes externally
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
setCustomFrom(dateRange.from);
|
|
174
|
+
setCustomTo(dateRange.to);
|
|
175
|
+
}, [dateRange.from, dateRange.to]);
|
|
176
|
+
|
|
177
|
+
const updatePosition = useCallback(() => {
|
|
178
|
+
if (triggerRef.current) {
|
|
179
|
+
const rect = triggerRef.current.getBoundingClientRect();
|
|
180
|
+
setCoords({
|
|
181
|
+
top: rect.top + window.scrollY,
|
|
182
|
+
bottom: rect.bottom + window.scrollY,
|
|
183
|
+
left: rect.left + window.scrollX,
|
|
184
|
+
right: window.innerWidth - rect.right - window.scrollX,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}, []);
|
|
188
|
+
|
|
189
|
+
const toggleDropdown = () => {
|
|
190
|
+
if (!isOpen) {
|
|
191
|
+
updatePosition();
|
|
192
|
+
setShowCustomPanel(value === 'custom');
|
|
193
|
+
}
|
|
194
|
+
setIsOpen((prev) => !prev);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const closeDropdown = () => setIsOpen(false);
|
|
198
|
+
|
|
199
|
+
// Close on outside click
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
202
|
+
const target = e.target as Element;
|
|
203
|
+
const clickedTrigger = triggerRef.current?.contains(target as Node);
|
|
204
|
+
const clickedContent = contentRef.current?.contains(target as Node);
|
|
205
|
+
if (!clickedTrigger && !clickedContent) {
|
|
206
|
+
setIsOpen(false);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const handleScroll = () => { if (isOpen) updatePosition(); };
|
|
210
|
+
const handleResize = () => { if (isOpen) updatePosition(); };
|
|
211
|
+
|
|
212
|
+
if (isOpen) {
|
|
213
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
214
|
+
window.addEventListener('scroll', handleScroll, true);
|
|
215
|
+
window.addEventListener('resize', handleResize);
|
|
216
|
+
}
|
|
217
|
+
return () => {
|
|
218
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
219
|
+
window.removeEventListener('scroll', handleScroll, true);
|
|
220
|
+
window.removeEventListener('resize', handleResize);
|
|
221
|
+
};
|
|
222
|
+
}, [isOpen, updatePosition]);
|
|
223
|
+
|
|
224
|
+
const handlePresetClick = (preset: DateRangePreset) => {
|
|
225
|
+
if (preset.key === 'custom') {
|
|
226
|
+
setShowCustomPanel(true);
|
|
227
|
+
return; // don't close, show custom date inputs
|
|
228
|
+
}
|
|
229
|
+
const resolved = preset.resolve();
|
|
230
|
+
onChange(preset.key, resolved);
|
|
231
|
+
setShowCustomPanel(false);
|
|
232
|
+
closeDropdown();
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const handleCustomApply = () => {
|
|
236
|
+
if (customFrom && customTo) {
|
|
237
|
+
// ensure from <= to
|
|
238
|
+
const f = customFrom <= customTo ? customFrom : customTo;
|
|
239
|
+
const t = customFrom <= customTo ? customTo : customFrom;
|
|
240
|
+
onChange('custom', { from: f, to: t });
|
|
241
|
+
closeDropdown();
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const displayLabel = useMemo(() => getDisplayLabel(value, dateRange), [value, dateRange]);
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<>
|
|
249
|
+
{/* Trigger */}
|
|
250
|
+
<div ref={triggerRef} className={cn("relative inline-block", className)}>
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
onClick={toggleDropdown}
|
|
254
|
+
className={cn(
|
|
255
|
+
"flex items-center gap-2 px-3 py-2.5 rounded-xl text-[12.5px] font-bold border transition-all",
|
|
256
|
+
"bg-white border-gray-200 hover:border-gray-300 text-gray-700",
|
|
257
|
+
isOpen && "border-primary-300 ring-2 ring-primary-100"
|
|
258
|
+
)}
|
|
259
|
+
>
|
|
260
|
+
<Calendar size={14} strokeWidth={2.5} className="text-gray-400 flex-shrink-0" />
|
|
261
|
+
<span className="truncate max-w-[180px]">{displayLabel}</span>
|
|
262
|
+
<ChevronDown
|
|
263
|
+
size={13}
|
|
264
|
+
strokeWidth={2.5}
|
|
265
|
+
className={cn(
|
|
266
|
+
"text-gray-400 transition-transform flex-shrink-0",
|
|
267
|
+
isOpen && "rotate-180"
|
|
268
|
+
)}
|
|
269
|
+
/>
|
|
270
|
+
</button>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
{/* Dropdown via portal */}
|
|
274
|
+
{isOpen && typeof document !== 'undefined' && createPortal(
|
|
275
|
+
<div
|
|
276
|
+
ref={contentRef}
|
|
277
|
+
style={{
|
|
278
|
+
position: 'absolute',
|
|
279
|
+
top: `${coords.bottom}px`,
|
|
280
|
+
...(align === 'left'
|
|
281
|
+
? { left: `${coords.left}px` }
|
|
282
|
+
: { right: `${coords.right}px` }),
|
|
283
|
+
}}
|
|
284
|
+
className="z-[9999] mt-1.5 rounded-[12px] bg-white border border-gray-200 shadow-[0_8px_30px_rgb(0,0,0,0.12)] animate-in fade-in zoom-in-95 duration-200 overflow-hidden"
|
|
285
|
+
>
|
|
286
|
+
<div className="flex">
|
|
287
|
+
{/* Preset list */}
|
|
288
|
+
<div className={cn(
|
|
289
|
+
"py-2 min-w-[180px]",
|
|
290
|
+
showCustomPanel && "border-r border-gray-100"
|
|
291
|
+
)}>
|
|
292
|
+
<p className="px-3 pb-1.5 pt-1 text-[10px] font-bold text-gray-400 uppercase tracking-wider">
|
|
293
|
+
Date Range
|
|
294
|
+
</p>
|
|
295
|
+
{PRESETS.map((preset) => (
|
|
296
|
+
<button
|
|
297
|
+
key={preset.key}
|
|
298
|
+
type="button"
|
|
299
|
+
onClick={() => handlePresetClick(preset)}
|
|
300
|
+
className={cn(
|
|
301
|
+
"w-full px-3 py-2 text-[13px] text-left transition-colors flex items-center gap-2",
|
|
302
|
+
(value === preset.key && !(preset.key === 'custom' && !showCustomPanel))
|
|
303
|
+
? "bg-primary-50 text-primary-700 font-bold"
|
|
304
|
+
: "text-gray-700 hover:bg-gray-50 font-medium"
|
|
305
|
+
)}
|
|
306
|
+
>
|
|
307
|
+
{value === preset.key && (
|
|
308
|
+
<Check size={13} strokeWidth={3} className="text-primary-600 flex-shrink-0" />
|
|
309
|
+
)}
|
|
310
|
+
<span className={cn(value !== preset.key && "pl-[21px]")}>
|
|
311
|
+
{preset.label}
|
|
312
|
+
</span>
|
|
313
|
+
</button>
|
|
314
|
+
))}
|
|
315
|
+
</div>
|
|
316
|
+
|
|
317
|
+
{/* Custom date panel */}
|
|
318
|
+
{showCustomPanel && (
|
|
319
|
+
<div className="p-3 min-w-[220px] flex flex-col gap-3">
|
|
320
|
+
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">
|
|
321
|
+
Custom Range
|
|
322
|
+
</p>
|
|
323
|
+
<div className="space-y-2">
|
|
324
|
+
<div>
|
|
325
|
+
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1 block">
|
|
326
|
+
From
|
|
327
|
+
</label>
|
|
328
|
+
<input
|
|
329
|
+
type="date"
|
|
330
|
+
value={customFrom}
|
|
331
|
+
onChange={(e) => setCustomFrom(e.target.value)}
|
|
332
|
+
className="appearance-none bg-white border border-gray-200 rounded-lg px-3 py-2 text-[12.5px] font-semibold text-gray-700 outline-none transition-all hover:border-gray-300 focus:border-primary-300 focus:ring-2 focus:ring-primary-100 w-full"
|
|
333
|
+
/>
|
|
334
|
+
</div>
|
|
335
|
+
<div>
|
|
336
|
+
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1 block">
|
|
337
|
+
To
|
|
338
|
+
</label>
|
|
339
|
+
<input
|
|
340
|
+
type="date"
|
|
341
|
+
value={customTo}
|
|
342
|
+
onChange={(e) => setCustomTo(e.target.value)}
|
|
343
|
+
className="appearance-none bg-white border border-gray-200 rounded-lg px-3 py-2 text-[12.5px] font-semibold text-gray-700 outline-none transition-all hover:border-gray-300 focus:border-primary-300 focus:ring-2 focus:ring-primary-100 w-full"
|
|
344
|
+
/>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
<button
|
|
348
|
+
type="button"
|
|
349
|
+
onClick={handleCustomApply}
|
|
350
|
+
disabled={!customFrom || !customTo}
|
|
351
|
+
className={cn(
|
|
352
|
+
"w-full py-2 rounded-lg text-[12.5px] font-bold transition-all",
|
|
353
|
+
customFrom && customTo
|
|
354
|
+
? "bg-primary-500 text-white hover:bg-primary-600 active:bg-primary-700"
|
|
355
|
+
: "bg-gray-100 text-gray-400 cursor-not-allowed"
|
|
356
|
+
)}
|
|
357
|
+
>
|
|
358
|
+
Apply Range
|
|
359
|
+
</button>
|
|
360
|
+
</div>
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
{/* Footer showing resolved range */}
|
|
365
|
+
{value !== 'custom' && (
|
|
366
|
+
<div className="px-3 py-2 border-t border-gray-100 bg-gray-50/50">
|
|
367
|
+
<p className="text-[11px] text-gray-500 font-medium">
|
|
368
|
+
<span className="font-bold text-gray-600">{formatDisplayDate(dateRange.from)}</span>
|
|
369
|
+
{dateRange.from !== dateRange.to && (
|
|
370
|
+
<>
|
|
371
|
+
<span className="mx-1">→</span>
|
|
372
|
+
<span className="font-bold text-gray-600">{formatDisplayDate(dateRange.to)}</span>
|
|
373
|
+
</>
|
|
374
|
+
)}
|
|
375
|
+
</p>
|
|
376
|
+
</div>
|
|
377
|
+
)}
|
|
378
|
+
</div>,
|
|
379
|
+
document.body
|
|
380
|
+
)}
|
|
381
|
+
</>
|
|
382
|
+
);
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// ── Utility: resolve a preset key to a DateRange ───────────────────
|
|
386
|
+
export const resolveDateRangePreset = (key: DateRangePresetKey): DateRange => {
|
|
387
|
+
const preset = PRESETS.find((p) => p.key === key);
|
|
388
|
+
if (!preset) return { from: fmt(today()), to: fmt(today()) };
|
|
389
|
+
return preset.resolve();
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
DateRangePicker.displayName = 'DateRangePicker';
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useRef } from "react";
|
|
4
|
+
import { Upload } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
export interface ImageDropzoneProps {
|
|
7
|
+
onDrop: (files: File[]) => void;
|
|
8
|
+
maxSizeMB?: number;
|
|
9
|
+
maxFiles?: number;
|
|
10
|
+
accept?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
subtitle?: string;
|
|
14
|
+
isLoading?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ImageDropzone({
|
|
18
|
+
onDrop,
|
|
19
|
+
maxSizeMB = 5,
|
|
20
|
+
maxFiles = 10,
|
|
21
|
+
accept = "image/*",
|
|
22
|
+
className = "",
|
|
23
|
+
title = "Drop images here or click to upload",
|
|
24
|
+
subtitle = "JPEG, PNG, WebP — max 5MB each, up to 10 images",
|
|
25
|
+
isLoading = false,
|
|
26
|
+
}: ImageDropzoneProps) {
|
|
27
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
28
|
+
|
|
29
|
+
const handleFileDrop = (e: React.DragEvent) => {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
if (isLoading) return;
|
|
32
|
+
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
|
33
|
+
if (files.length) {
|
|
34
|
+
onDrop(files.slice(0, maxFiles));
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
39
|
+
if (isLoading) return;
|
|
40
|
+
const files = Array.from(e.target.files || []);
|
|
41
|
+
if (files.length) {
|
|
42
|
+
onDrop(files.slice(0, maxFiles));
|
|
43
|
+
}
|
|
44
|
+
if (fileInputRef.current) {
|
|
45
|
+
fileInputRef.current.value = "";
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
onDragOver={(e) => e.preventDefault()}
|
|
52
|
+
onDrop={handleFileDrop}
|
|
53
|
+
onClick={() => fileInputRef.current?.click()}
|
|
54
|
+
className={`border-2 border-dashed border-gray-200 hover:border-primary-300 rounded-xl p-6 flex flex-col items-center justify-center cursor-pointer transition-all hover:bg-primary-50/30 group ${
|
|
55
|
+
isLoading ? "opacity-50 cursor-not-allowed" : ""
|
|
56
|
+
} ${className}`}
|
|
57
|
+
>
|
|
58
|
+
<div className="w-10 h-10 rounded-full bg-gray-100 group-hover:bg-primary-100 flex items-center justify-center mb-2 transition-colors">
|
|
59
|
+
{isLoading ? (
|
|
60
|
+
<div className="w-5 h-5 rounded-full border-2 border-gray-200 border-t-primary-500 animate-spin" />
|
|
61
|
+
) : (
|
|
62
|
+
<Upload size={18} className="text-gray-400 group-hover:text-primary-500" />
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
<p className="text-[13px] font-medium text-gray-500 group-hover:text-primary-600">
|
|
66
|
+
{isLoading ? "Uploading..." : title}
|
|
67
|
+
</p>
|
|
68
|
+
<p className="text-[11px] text-gray-400 mt-0.5">{subtitle}</p>
|
|
69
|
+
<input
|
|
70
|
+
ref={fileInputRef}
|
|
71
|
+
type="file"
|
|
72
|
+
accept={accept}
|
|
73
|
+
multiple={maxFiles > 1}
|
|
74
|
+
className="hidden"
|
|
75
|
+
onChange={handleFileSelect}
|
|
76
|
+
disabled={isLoading}
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import React, { useEffect } from 'react';
|
|
3
|
+
import React, { useEffect, useState } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
4
5
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
5
6
|
import { X } from 'lucide-react';
|
|
6
7
|
import { cn } from '@apptimate/core-lib';
|
|
@@ -15,9 +16,15 @@ export interface ModalProps {
|
|
|
15
16
|
backdrop?: 'transparent' | 'opaque' | 'blur';
|
|
16
17
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
|
17
18
|
position?: 'center' | 'bottom';
|
|
19
|
+
/** Override z-index layer (default 50). Increase for nested modals. */
|
|
20
|
+
zIndex?: number;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
export const Modal = ({ isOpen, onClose, title, children, className, footer, backdrop = 'opaque', size = 'sm', position = 'center' }: ModalProps) => {
|
|
23
|
+
export const Modal = ({ isOpen, onClose, title, children, className, footer, backdrop = 'opaque', size = 'sm', position = 'center', zIndex }: ModalProps) => {
|
|
24
|
+
// SSR-safe portal mount guard — document is only available on the client
|
|
25
|
+
const [mounted, setMounted] = useState(false);
|
|
26
|
+
useEffect(() => { setMounted(true); }, []);
|
|
27
|
+
|
|
21
28
|
useEffect(() => {
|
|
22
29
|
if (isOpen) {
|
|
23
30
|
document.body.style.overflow = 'hidden';
|
|
@@ -44,14 +51,14 @@ export const Modal = ({ isOpen, onClose, title, children, className, footer, bac
|
|
|
44
51
|
full: 'max-w-[100vw]',
|
|
45
52
|
};
|
|
46
53
|
|
|
47
|
-
|
|
54
|
+
const content = (
|
|
48
55
|
<AnimatePresence>
|
|
49
56
|
{isOpen && (
|
|
50
57
|
<div className={cn(
|
|
51
|
-
"fixed inset-0
|
|
58
|
+
"fixed inset-0 flex",
|
|
52
59
|
position === 'center' ? "items-center justify-center p-2 sm:p-4" : "items-end justify-center sm:items-center p-0 sm:p-4",
|
|
53
60
|
size === 'full' ? 'p-0 sm:p-0' : ''
|
|
54
|
-
)}>
|
|
61
|
+
)} style={{ zIndex: zIndex ?? 50 }}>
|
|
55
62
|
<motion.div
|
|
56
63
|
initial={{ opacity: 0 }}
|
|
57
64
|
animate={{ opacity: 1 }}
|
|
@@ -73,7 +80,7 @@ export const Modal = ({ isOpen, onClose, title, children, className, footer, bac
|
|
|
73
80
|
>
|
|
74
81
|
{/* Header */}
|
|
75
82
|
<div className="flex items-center justify-between px-5 sm:px-8 py-4 border-b border-surface-0">
|
|
76
|
-
<h3 className="text-[16px] sm:text-[18px] font-bold text-foreground-0 tracking-tight">{title}</h3>
|
|
83
|
+
<h3 className="text-[16px] sm:text-[18px] font-bold text-foreground-0 tracking-tight flex-1 min-w-0">{title}</h3>
|
|
77
84
|
<button
|
|
78
85
|
onClick={onClose}
|
|
79
86
|
className="w-8 h-8 rounded-full flex items-center justify-center text-foreground-disabled hover:bg-surface-0 hover:text-foreground-0 transition-all"
|
|
@@ -92,7 +99,7 @@ export const Modal = ({ isOpen, onClose, title, children, className, footer, bac
|
|
|
92
99
|
|
|
93
100
|
{/* Footer */}
|
|
94
101
|
{footer && (
|
|
95
|
-
<div className="px-5 sm:px-8 pb-5
|
|
102
|
+
<div className="px-5 sm:px-8 pb-5 flex flex-col-reverse sm:flex-row justify-end gap-2 sm:gap-3">
|
|
96
103
|
{footer}
|
|
97
104
|
</div>
|
|
98
105
|
)}
|
|
@@ -101,6 +108,11 @@ export const Modal = ({ isOpen, onClose, title, children, className, footer, bac
|
|
|
101
108
|
)}
|
|
102
109
|
</AnimatePresence>
|
|
103
110
|
);
|
|
111
|
+
|
|
112
|
+
// Always render via a portal into <body> so the modal escapes any parent
|
|
113
|
+
// stacking context created by Framer Motion transforms or CSS transforms.
|
|
114
|
+
if (!mounted) return null;
|
|
115
|
+
return createPortal(content, document.body);
|
|
104
116
|
};
|
|
105
117
|
|
|
106
118
|
export const ModalFooter = ({ children, className }: { children: React.ReactNode; className?: string }) => {
|
|
@@ -12,8 +12,8 @@ export interface PopConfirmProps {
|
|
|
12
12
|
/** Element that triggers the popover (e.g. Button) */
|
|
13
13
|
trigger: React.ReactElement<{ onClick?: (e: React.MouseEvent) => void }>;
|
|
14
14
|
|
|
15
|
-
/** Called when user confirms. Call callback() to close.
|
|
16
|
-
onConfirm: (callback: () => void, setLoading: (loading: boolean) => void) => void
|
|
15
|
+
/** Called when user confirms. Call callback() to close. Returning a Promise automatically handles loading state. */
|
|
16
|
+
onConfirm: (callback: () => void, setLoading: (loading: boolean) => void) => void | Promise<any>;
|
|
17
17
|
|
|
18
18
|
/** Called when user cancels. Call callback() to close. */
|
|
19
19
|
onCancel: (callback: () => void) => void;
|
|
@@ -76,8 +76,19 @@ export const PopConfirm = ({
|
|
|
76
76
|
setIsLoading(false);
|
|
77
77
|
}, []);
|
|
78
78
|
|
|
79
|
-
const handleConfirm = useCallback(() => {
|
|
80
|
-
|
|
79
|
+
const handleConfirm = useCallback(async () => {
|
|
80
|
+
setIsLoading(true);
|
|
81
|
+
try {
|
|
82
|
+
const result = onConfirm(close, setIsLoading);
|
|
83
|
+
if (result instanceof Promise) {
|
|
84
|
+
await result;
|
|
85
|
+
// Automatically close if the promise resolves successfully
|
|
86
|
+
close();
|
|
87
|
+
}
|
|
88
|
+
} catch (e) {
|
|
89
|
+
console.error(e);
|
|
90
|
+
setIsLoading(false);
|
|
91
|
+
}
|
|
81
92
|
}, [onConfirm, close]);
|
|
82
93
|
|
|
83
94
|
const handleCancel = useCallback(() => {
|