@apptimate/ui 1.0.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/CHANGELOG.md +7 -7
- package/package.json +22 -18
- package/src/{Button.tsx → base-components/Button.tsx} +21 -13
- package/src/{ChipInput.tsx → base-components/ChipInput.tsx} +1 -1
- package/src/base-components/DateLabel.tsx +68 -0
- package/src/base-components/DateRangePicker.tsx +392 -0
- package/src/base-components/DesktopFilterPopover.tsx +53 -0
- package/src/base-components/DesktopSearchPopover.tsx +51 -0
- package/src/{Dropdown.tsx → base-components/Dropdown.tsx} +11 -5
- package/src/base-components/ImageDropzone.tsx +80 -0
- package/src/{Input.tsx → base-components/Input.tsx} +8 -7
- package/src/base-components/Label.tsx +24 -0
- package/src/{Modal.tsx → base-components/Modal.tsx} +36 -10
- package/src/{PopConfirm.tsx → base-components/PopConfirm.tsx} +15 -4
- package/src/base-components/RecordCount.tsx +19 -0
- package/src/{SearchableSelect.tsx → base-components/SearchableSelect.tsx} +9 -9
- package/src/{Select.tsx → base-components/Select.tsx} +7 -6
- package/src/base-components/Table.tsx +83 -0
- package/src/base-components/TableMobileOptions.tsx +51 -0
- package/src/common-components/DashboardLayout.tsx +473 -0
- package/src/common-components/charts/ApexCharts.tsx +222 -0
- 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 +30 -18
- package/src/Table.tsx +0 -78
- /package/src/{Accordion.tsx → base-components/Accordion.tsx} +0 -0
- /package/src/{Badge.tsx → base-components/Badge.tsx} +0 -0
- /package/src/{ButtonGroup.tsx → base-components/ButtonGroup.tsx} +0 -0
- /package/src/{Card.tsx → base-components/Card.tsx} +0 -0
- /package/src/{Checkbox.tsx → base-components/Checkbox.tsx} +0 -0
- /package/src/{Divider.tsx → base-components/Divider.tsx} +0 -0
- /package/src/{EmptyState.tsx → base-components/EmptyState.tsx} +0 -0
- /package/src/{HintIcon.tsx → base-components/HintIcon.tsx} +0 -0
- /package/src/{Pagination.tsx → base-components/Pagination.tsx} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
# @apptimate/ui
|
|
2
|
-
|
|
3
|
-
## 1.0.0
|
|
4
|
-
|
|
5
|
-
### Major Changes
|
|
6
|
-
|
|
7
|
-
- init
|
|
1
|
+
# @apptimate/ui
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- init
|
package/package.json
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@apptimate/ui",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"main": "src/index.tsx",
|
|
5
|
-
"types": "src/index.tsx",
|
|
6
|
-
"dependencies": {
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"react": "
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@apptimate/ui",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"main": "src/index.tsx",
|
|
5
|
+
"types": "src/index.tsx",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@apptimate/core-lib": "*",
|
|
8
|
+
"class-variance-authority": "^0.7.1",
|
|
9
|
+
"clsx": "^2.1.1",
|
|
10
|
+
"framer-motion": "^12.38.0",
|
|
11
|
+
"lucide-react": "^1.14.0",
|
|
12
|
+
"react": "^19.2.5",
|
|
13
|
+
"recharts": "^2.15.4",
|
|
14
|
+
"tailwind-merge": "^3.5.0"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"next": "^16.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -14,7 +14,7 @@ export interface ButtonProps {
|
|
|
14
14
|
children?: React.ReactNode;
|
|
15
15
|
|
|
16
16
|
/** The visual style variant of the button */
|
|
17
|
-
variant?: 'solid' | 'bordered' | 'light' | 'flat' | 'shadow' | 'ghost';
|
|
17
|
+
variant?: 'solid' | 'bordered' | 'light' | 'flat' | 'shadow' | 'ghost' | 'soft';
|
|
18
18
|
|
|
19
19
|
/** The color scheme of the button */
|
|
20
20
|
color?: 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'danger';
|
|
@@ -73,7 +73,7 @@ const iconSizeMap = {
|
|
|
73
73
|
|
|
74
74
|
export const buttonVariants = cva(
|
|
75
75
|
[
|
|
76
|
-
'inline-flex items-center justify-center gap-2 font-[inherit] font-medium cursor-pointer transition-all duration-200 relative whitespace-nowrap select-none outline-none overflow-hidden',
|
|
76
|
+
'inline-flex items-center justify-center gap-2 font-[inherit] font-medium cursor-pointer transition-all duration-200 relative whitespace-nowrap select-none outline-none overflow-hidden shrink-0 shadow-[0_1px_2px_rgba(0,0,0,0.02)]',
|
|
77
77
|
'focus-visible:outline-2 focus-visible:outline-[var(--aceui-color-focus)] focus-visible:outline-offset-2',
|
|
78
78
|
],
|
|
79
79
|
{
|
|
@@ -85,6 +85,7 @@ export const buttonVariants = cva(
|
|
|
85
85
|
flat: '',
|
|
86
86
|
shadow: '',
|
|
87
87
|
ghost: '',
|
|
88
|
+
soft: '',
|
|
88
89
|
},
|
|
89
90
|
color: {
|
|
90
91
|
default: '',
|
|
@@ -95,14 +96,14 @@ export const buttonVariants = cva(
|
|
|
95
96
|
danger: '',
|
|
96
97
|
},
|
|
97
98
|
size: {
|
|
98
|
-
small: 'text-[0.75rem] leading-[1rem] px-3
|
|
99
|
-
medium: 'text-[0.
|
|
100
|
-
large: 'text-[1rem] leading-[1.5rem] px-6
|
|
99
|
+
small: 'text-[0.75rem] leading-[1rem] px-3 h-8',
|
|
100
|
+
medium: 'text-[0.8125rem] leading-[1.25rem] px-4 h-10',
|
|
101
|
+
large: 'text-[1rem] leading-[1.5rem] px-6 h-12',
|
|
101
102
|
},
|
|
102
103
|
radius: {
|
|
103
104
|
none: 'rounded-none',
|
|
104
105
|
small: 'rounded-[8px]',
|
|
105
|
-
medium: 'rounded-[
|
|
106
|
+
medium: 'rounded-[10px]',
|
|
106
107
|
large: 'rounded-[14px]',
|
|
107
108
|
full: 'rounded-full',
|
|
108
109
|
squircle: 'rounded-full',
|
|
@@ -129,12 +130,12 @@ export const buttonVariants = cva(
|
|
|
129
130
|
{ variant: 'solid', color: 'warning', class: 'border-none bg-warning-500 text-warning-foreground hover:bg-warning-600 active:bg-warning-700' },
|
|
130
131
|
{ variant: 'solid', color: 'danger', class: 'border-none bg-danger-500 text-danger-foreground hover:bg-danger-600 active:bg-danger-700' },
|
|
131
132
|
/* Bordered */
|
|
132
|
-
{ variant: 'bordered', color: 'default', class: 'bg-transparent text-default-500 border
|
|
133
|
-
{ variant: 'bordered', color: 'primary', class: 'bg-transparent text-primary-500 border
|
|
134
|
-
{ variant: 'bordered', color: 'secondary', class: 'bg-transparent text-secondary-500 border
|
|
135
|
-
{ variant: 'bordered', color: 'success', class: 'bg-transparent text-success-500 border
|
|
136
|
-
{ variant: 'bordered', color: 'warning', class: 'bg-transparent text-warning-500 border
|
|
137
|
-
{ variant: 'bordered', color: 'danger', class: 'bg-transparent text-danger-500 border
|
|
133
|
+
{ variant: 'bordered', color: 'default', class: 'bg-transparent text-default-500 border border-default-500 hover:bg-default-50' },
|
|
134
|
+
{ variant: 'bordered', color: 'primary', class: 'bg-transparent text-primary-500 border border-primary-500 hover:bg-primary-50' },
|
|
135
|
+
{ variant: 'bordered', color: 'secondary', class: 'bg-transparent text-secondary-500 border border-secondary-500 hover:bg-secondary-50' },
|
|
136
|
+
{ variant: 'bordered', color: 'success', class: 'bg-transparent text-success-500 border border-success-500 hover:bg-success-50' },
|
|
137
|
+
{ variant: 'bordered', color: 'warning', class: 'bg-transparent text-warning-500 border border-warning-500 hover:bg-warning-50' },
|
|
138
|
+
{ variant: 'bordered', color: 'danger', class: 'bg-transparent text-danger-500 border border-danger-500 hover:bg-danger-50' },
|
|
138
139
|
/* Light */
|
|
139
140
|
{ variant: 'light', color: 'default', class: 'bg-default-50 text-default-500 border-none hover:bg-default-100' },
|
|
140
141
|
{ variant: 'light', color: 'primary', class: 'bg-primary-50 text-primary-500 border-none hover:bg-primary-100' },
|
|
@@ -149,6 +150,13 @@ export const buttonVariants = cva(
|
|
|
149
150
|
{ variant: 'flat', color: 'success', class: 'bg-transparent text-success-500 border-none hover:bg-success-50' },
|
|
150
151
|
{ variant: 'flat', color: 'warning', class: 'bg-transparent text-warning-500 border-none hover:bg-warning-50' },
|
|
151
152
|
{ variant: 'flat', color: 'danger', class: 'bg-transparent text-danger-500 border-none hover:bg-danger-50' },
|
|
153
|
+
/* Soft */
|
|
154
|
+
{ variant: 'soft', color: 'default', class: 'bg-gray-50 text-gray-700 border border-gray-100 hover:bg-gray-100 hover:border-gray-200' },
|
|
155
|
+
{ variant: 'soft', color: 'primary', class: 'bg-primary-50 text-primary-700 border border-primary-200 hover:bg-primary-100 hover:border-primary-300' },
|
|
156
|
+
{ variant: 'soft', color: 'secondary', class: 'bg-secondary-50 text-secondary-700 border border-secondary-200 hover:bg-secondary-100 hover:border-secondary-300' },
|
|
157
|
+
{ variant: 'soft', color: 'success', class: 'bg-success-50 text-success-700 border border-success-200 hover:bg-success-100 hover:border-success-300' },
|
|
158
|
+
{ variant: 'soft', color: 'warning', class: 'bg-warning-50 text-warning-700 border border-warning-200 hover:bg-warning-100 hover:border-warning-300' },
|
|
159
|
+
{ variant: 'soft', color: 'danger', class: 'bg-danger-50 text-danger-700 border border-danger-200 hover:bg-danger-100 hover:border-danger-300' },
|
|
152
160
|
/* Shadow */
|
|
153
161
|
{ variant: 'shadow', color: 'default', class: 'border-none bg-default-500 text-default-foreground hover:bg-default-600 active:bg-default-700' },
|
|
154
162
|
{ variant: 'shadow', color: 'primary', class: 'border-none bg-primary-500 text-primary-foreground hover:bg-primary-600 active:bg-primary-700' },
|
|
@@ -197,7 +205,7 @@ export const Button = ({
|
|
|
197
205
|
variant = 'solid',
|
|
198
206
|
color = 'primary',
|
|
199
207
|
size = 'medium',
|
|
200
|
-
radius = '
|
|
208
|
+
radius = 'full',
|
|
201
209
|
isDisabled = false,
|
|
202
210
|
isLoading = false,
|
|
203
211
|
icon,
|
|
@@ -136,7 +136,7 @@ export const ChipInput = ({
|
|
|
136
136
|
<div
|
|
137
137
|
className={cn(
|
|
138
138
|
'flex flex-wrap items-center gap-1.5 px-3 py-2 rounded-[10px] border bg-surface-0 transition-colors min-h-[42px] cursor-text',
|
|
139
|
-
error ? 'border-danger-500' : 'border-border-default focus-within:border-
|
|
139
|
+
error ? 'border-danger-500 hover:border-danger-500' : 'border-border-default hover:border-gray-300 focus-within:border-gray-300',
|
|
140
140
|
disabled && 'opacity-50 pointer-events-none'
|
|
141
141
|
)}
|
|
142
142
|
onClick={() => inputRef.current?.focus()}
|
|
@@ -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,53 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { SlidersHorizontal } from 'lucide-react';
|
|
3
|
+
import { Dropdown } from './Dropdown';
|
|
4
|
+
import { Button } from './Button';
|
|
5
|
+
import { cn } from '@apptimate/core-lib';
|
|
6
|
+
|
|
7
|
+
export interface DesktopFilterPopoverProps {
|
|
8
|
+
/** Content to render inside the filter popover (typically Select components) */
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
/** Custom label for the trigger button aria-label */
|
|
11
|
+
label?: string;
|
|
12
|
+
/** Additional class for outer wrapper */
|
|
13
|
+
className?: string;
|
|
14
|
+
/** Additional class for the trigger button */
|
|
15
|
+
triggerClassName?: string;
|
|
16
|
+
/** Whether any filter is active — shows a visual indicator dot */
|
|
17
|
+
hasActiveFilter?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DesktopFilterPopover = ({
|
|
21
|
+
children,
|
|
22
|
+
label = "Filters",
|
|
23
|
+
className,
|
|
24
|
+
triggerClassName,
|
|
25
|
+
hasActiveFilter = false,
|
|
26
|
+
}: DesktopFilterPopoverProps) => {
|
|
27
|
+
return (
|
|
28
|
+
<div className={cn("hidden lg:block relative", className)}>
|
|
29
|
+
<Dropdown
|
|
30
|
+
align="right"
|
|
31
|
+
contentClassName="w-[260px] p-3 space-y-3"
|
|
32
|
+
trigger={
|
|
33
|
+
<div className="relative">
|
|
34
|
+
<Button
|
|
35
|
+
variant="soft"
|
|
36
|
+
color="default"
|
|
37
|
+
className={cn("!p-0 text-gray-500", triggerClassName)}
|
|
38
|
+
icon={<SlidersHorizontal size={18} strokeWidth={2} />}
|
|
39
|
+
ariaLabel={label}
|
|
40
|
+
/>
|
|
41
|
+
{hasActiveFilter && (
|
|
42
|
+
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-primary-500 rounded-full ring-2 ring-white" />
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
}
|
|
46
|
+
>
|
|
47
|
+
<div onClick={(e) => e.stopPropagation()} className="space-y-3">
|
|
48
|
+
{children}
|
|
49
|
+
</div>
|
|
50
|
+
</Dropdown>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
};
|