@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +7 -7
  2. package/package.json +22 -18
  3. package/src/{Button.tsx → base-components/Button.tsx} +21 -13
  4. package/src/{ChipInput.tsx → base-components/ChipInput.tsx} +1 -1
  5. package/src/base-components/DateLabel.tsx +68 -0
  6. package/src/base-components/DateRangePicker.tsx +392 -0
  7. package/src/base-components/DesktopFilterPopover.tsx +53 -0
  8. package/src/base-components/DesktopSearchPopover.tsx +51 -0
  9. package/src/{Dropdown.tsx → base-components/Dropdown.tsx} +11 -5
  10. package/src/base-components/ImageDropzone.tsx +80 -0
  11. package/src/{Input.tsx → base-components/Input.tsx} +8 -7
  12. package/src/base-components/Label.tsx +24 -0
  13. package/src/{Modal.tsx → base-components/Modal.tsx} +36 -10
  14. package/src/{PopConfirm.tsx → base-components/PopConfirm.tsx} +15 -4
  15. package/src/base-components/RecordCount.tsx +19 -0
  16. package/src/{SearchableSelect.tsx → base-components/SearchableSelect.tsx} +9 -9
  17. package/src/{Select.tsx → base-components/Select.tsx} +7 -6
  18. package/src/base-components/Table.tsx +83 -0
  19. package/src/base-components/TableMobileOptions.tsx +51 -0
  20. package/src/common-components/DashboardLayout.tsx +473 -0
  21. package/src/common-components/charts/ApexCharts.tsx +222 -0
  22. package/src/common-components/charts/RBarChart.tsx +145 -0
  23. package/src/common-components/charts/RLineChart.tsx +116 -0
  24. package/src/common-components/charts/index.ts +3 -0
  25. package/src/common-components/charts/palette.ts +10 -0
  26. package/src/components/shared/CustomerSelectorComponent.tsx +163 -0
  27. package/src/components/shared/ImageUploadComponent.tsx +228 -0
  28. package/src/components/shared/PaymentModeComponent.tsx +421 -0
  29. package/src/components/shared/ProcurementManagerSelector.tsx +5 -0
  30. package/src/components/shared/ProductSelectorComponent.tsx +166 -0
  31. package/src/components/shared/SupplierSelectorComponent.tsx +5 -0
  32. package/src/index.tsx +30 -18
  33. package/src/Table.tsx +0 -78
  34. /package/src/{Accordion.tsx → base-components/Accordion.tsx} +0 -0
  35. /package/src/{Badge.tsx → base-components/Badge.tsx} +0 -0
  36. /package/src/{ButtonGroup.tsx → base-components/ButtonGroup.tsx} +0 -0
  37. /package/src/{Card.tsx → base-components/Card.tsx} +0 -0
  38. /package/src/{Checkbox.tsx → base-components/Checkbox.tsx} +0 -0
  39. /package/src/{Divider.tsx → base-components/Divider.tsx} +0 -0
  40. /package/src/{EmptyState.tsx → base-components/EmptyState.tsx} +0 -0
  41. /package/src/{HintIcon.tsx → base-components/HintIcon.tsx} +0 -0
  42. /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.0.0",
4
- "main": "src/index.tsx",
5
- "types": "src/index.tsx",
6
- "dependencies": {
7
- "class-variance-authority": "^0.7.1",
8
- "clsx": "^2.1.1",
9
- "framer-motion": "^12.38.0",
10
- "lucide-react": "^1.14.0",
11
- "react": "latest",
12
- "tailwind-merge": "^3.5.0",
13
- "@apptimate/core-lib": "*"
14
- },
15
- "publishConfig": {
16
- "access": "public"
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 min-h-8',
99
- medium: 'text-[0.875rem] leading-[1.25rem] px-4 min-h-10',
100
- large: 'text-[1rem] leading-[1.5rem] px-6 min-h-12',
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-[12px]',
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-1 border-default-500 hover:bg-default-50' },
133
- { variant: 'bordered', color: 'primary', class: 'bg-transparent text-primary-500 border-1 border-primary-500 hover:bg-primary-50' },
134
- { variant: 'bordered', color: 'secondary', class: 'bg-transparent text-secondary-500 border-1 border-secondary-500 hover:bg-secondary-50' },
135
- { variant: 'bordered', color: 'success', class: 'bg-transparent text-success-500 border-1 border-success-500 hover:bg-success-50' },
136
- { variant: 'bordered', color: 'warning', class: 'bg-transparent text-warning-500 border-1 border-warning-500 hover:bg-warning-50' },
137
- { variant: 'bordered', color: 'danger', class: 'bg-transparent text-danger-500 border-1 border-danger-500 hover:bg-danger-50' },
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 = 'squircle',
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-primary-500',
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
+ };