@apptimate/ui 5.2.0 → 5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apptimate/ui",
3
- "version": "5.2.0",
3
+ "version": "5.3.0",
4
4
  "main": "src/index.tsx",
5
5
  "types": "src/index.tsx",
6
6
  "dependencies": {
@@ -14,7 +14,8 @@
14
14
  "react-apexcharts": "^2.1.1",
15
15
  "recharts": "^2.15.4",
16
16
  "tailwind-merge": "^3.5.0",
17
- "use-debounce": "^10.1.1"
17
+ "use-debounce": "^10.1.1",
18
+ "xlsx": "^0.18.5"
18
19
  },
19
20
  "publishConfig": {
20
21
  "access": "public"
@@ -0,0 +1,203 @@
1
+ "use client";
2
+
3
+ import React, { useState, useCallback, useEffect } from "react";
4
+ import { BookOpen } from "lucide-react";
5
+ import { EntityPickerModal, PickerItem, PickerTrigger } from "@apptimate/ui";
6
+
7
+ interface AccountOption {
8
+ id: number;
9
+ code: string;
10
+ name: string;
11
+ type: string;
12
+ }
13
+
14
+ interface ChartOfAccountPickerProps {
15
+ value: number | string;
16
+ displayValue?: string | null;
17
+ onChange: (accountId: number | string, account?: AccountOption) => void;
18
+ accounts?: AccountOption[];
19
+ fetchAccounts?: (type?: string) => Promise<{ is_success: boolean; result?: AccountOption[] }>;
20
+ label?: string;
21
+ placeholder?: string;
22
+ isRequired?: boolean;
23
+ /** Use compact trigger style for grid/table rows */
24
+ compact?: boolean;
25
+ filterType?: string;
26
+ }
27
+
28
+ const TYPE_LABELS: Record<string, string> = {
29
+ asset: "Assets",
30
+ liability: "Liabilities",
31
+ equity: "Equity",
32
+ revenue: "Revenue",
33
+ expense: "Expenses",
34
+ };
35
+
36
+ const TYPE_COLORS: Record<string, string> = {
37
+ asset: "text-blue-500",
38
+ liability: "text-amber-500",
39
+ equity: "text-violet-500",
40
+ revenue: "text-emerald-500",
41
+ expense: "text-red-500",
42
+ };
43
+
44
+ /**
45
+ * Chart of Accounts picker using the EntityPickerModal pattern.
46
+ * Opens a searchable popup grouped by account type.
47
+ */
48
+ export function ChartOfAccountPicker({
49
+ value,
50
+ displayValue,
51
+ onChange,
52
+ accounts: externalAccounts,
53
+ fetchAccounts,
54
+ label = "Account",
55
+ placeholder = "Select account…",
56
+ isRequired = false,
57
+ compact = false,
58
+ filterType,
59
+ }: ChartOfAccountPickerProps) {
60
+ const [isOpen, setIsOpen] = useState(false);
61
+ const [search, setSearch] = useState("");
62
+ const [accounts, setAccounts] = useState<AccountOption[]>(externalAccounts || []);
63
+ const [isLoading, setIsLoading] = useState(false);
64
+
65
+ useEffect(() => {
66
+ if (externalAccounts?.length) setAccounts(externalAccounts);
67
+ }, [externalAccounts]);
68
+
69
+ useEffect(() => {
70
+ setAccounts([]);
71
+ }, [filterType]);
72
+
73
+ const fetchData = useCallback(async () => {
74
+ if (accounts.length > 0) return;
75
+ if (!fetchAccounts) return;
76
+ setIsLoading(true);
77
+ try {
78
+ const res = await fetchAccounts(filterType);
79
+ if (res.is_success) setAccounts(res.result || []);
80
+ } catch {}
81
+ setIsLoading(false);
82
+ }, [accounts.length, filterType, fetchAccounts]);
83
+
84
+ const handleOpen = () => {
85
+ setSearch("");
86
+ setIsOpen(true);
87
+ fetchData();
88
+ };
89
+
90
+ const handleSelect = (account: AccountOption) => {
91
+ onChange(account.id, account);
92
+ setIsOpen(false);
93
+ };
94
+
95
+ const handleClear = () => {
96
+ onChange("", undefined);
97
+ };
98
+
99
+ const selectedAccount = accounts.find(a => String(a.id) === String(value));
100
+ const currentDisplay = displayValue || (selectedAccount ? `${selectedAccount.code} — ${selectedAccount.name}` : null);
101
+
102
+ const searchLower = (search || "").toLowerCase();
103
+ const filtered = accounts.filter(a =>
104
+ a.code.toLowerCase().includes(searchLower) ||
105
+ a.name.toLowerCase().includes(searchLower) ||
106
+ a.type.toLowerCase().includes(searchLower)
107
+ );
108
+
109
+ // Group by account type
110
+ const grouped = filtered.reduce<Record<string, AccountOption[]>>((acc, item) => {
111
+ const type = item.type || "other";
112
+ if (!acc[type]) acc[type] = [];
113
+ acc[type].push(item);
114
+ return acc;
115
+ }, {});
116
+ const typeOrder = ["asset", "liability", "equity", "revenue", "expense"];
117
+ const sortedTypes = Object.keys(grouped).sort((a, b) => {
118
+ const ia = typeOrder.indexOf(a), ib = typeOrder.indexOf(b);
119
+ return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
120
+ });
121
+
122
+ return (
123
+ <>
124
+ {compact ? (
125
+ /* Compact trigger for grid/table rows — matches the input height */
126
+ <button
127
+ type="button"
128
+ onClick={handleOpen}
129
+ className={`w-full flex items-center justify-between bg-surface-0 border-[1.5px] border-border-subtle rounded-lg px-2.5 py-2 text-[12px] text-left outline-none hover:border-gray-300 focus:border-gray-400 transition-colors ${
130
+ currentDisplay ? "text-gray-800 font-medium" : "text-gray-400"
131
+ }`}
132
+ >
133
+ <span className="truncate">{currentDisplay || placeholder}</span>
134
+ <BookOpen size={12} className="text-gray-400 flex-shrink-0 ml-1" />
135
+ </button>
136
+ ) : (
137
+ <PickerTrigger
138
+ label={label}
139
+ value={currentDisplay}
140
+ placeholder={placeholder}
141
+ isRequired={isRequired}
142
+ onClick={handleOpen}
143
+ onClear={value ? handleClear : undefined}
144
+ />
145
+ )}
146
+
147
+ <EntityPickerModal
148
+ isOpen={isOpen}
149
+ onClose={() => setIsOpen(false)}
150
+ onSelect={handleSelect}
151
+ title="Select Account"
152
+ searchPlaceholder="Search by code or name…"
153
+ selectedId={value}
154
+ search={search}
155
+ onSearchChange={setSearch}
156
+ size="md"
157
+ >
158
+ {isLoading ? (
159
+ <div className="py-12 text-center">
160
+ <div className="inline-block h-6 w-6 rounded-full border-2 border-gray-200 border-t-primary-500 animate-spin" />
161
+ <p className="mt-3 text-sm text-gray-400">Loading accounts…</p>
162
+ </div>
163
+ ) : filtered.length === 0 ? (
164
+ <div className="py-12 text-center">
165
+ <BookOpen size={32} className="mx-auto text-gray-300 mb-2" />
166
+ <p className="text-sm text-gray-400">No accounts found</p>
167
+ </div>
168
+ ) : (
169
+ <div className="py-1">
170
+ {sortedTypes.map(type => (
171
+ <div key={type}>
172
+ {/* Type group header */}
173
+ <div className="px-3 py-1.5 sticky top-0 bg-gray-50/95 backdrop-blur-sm z-10">
174
+ <span className={`text-[9px] font-extrabold uppercase tracking-widest ${TYPE_COLORS[type] || "text-gray-400"}`}>
175
+ {TYPE_LABELS[type] || type}
176
+ </span>
177
+ </div>
178
+
179
+ {/* Account items */}
180
+ <div className="space-y-0.5 px-1">
181
+ {grouped[type].map(account => (
182
+ <PickerItem
183
+ key={account.id}
184
+ label={account.name}
185
+ sublabel={`Code: ${account.code}`}
186
+ isSelected={String(value) === String(account.id)}
187
+ onClick={() => handleSelect(account)}
188
+ trailing={
189
+ <code className="text-[10px] font-mono text-gray-400 flex-shrink-0">
190
+ {account.code}
191
+ </code>
192
+ }
193
+ />
194
+ ))}
195
+ </div>
196
+ </div>
197
+ ))}
198
+ </div>
199
+ )}
200
+ </EntityPickerModal>
201
+ </>
202
+ );
203
+ }
@@ -0,0 +1,204 @@
1
+ "use client";
2
+
3
+ import { cn } from '@apptimate/core-lib';
4
+ import { Check, Paintbrush } from 'lucide-react';
5
+ import React, { useState, useEffect, useRef } from 'react';
6
+ import { Label } from './Label';
7
+
8
+ export interface ColorPickerProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
9
+ label?: string;
10
+ error?: string;
11
+ isRequired?: boolean;
12
+ onChange?: (e: any) => void;
13
+ }
14
+
15
+ const PRESET_COLORS = [
16
+ "#6366F1", // Indigo
17
+ "#3B82F6", // Blue
18
+ "#06B6D4", // Cyan
19
+ "#14B8A6", // Teal
20
+ "#10B981", // Emerald
21
+ "#8B5CF6", // Purple
22
+ "#EC4899", // Pink
23
+ "#EF4444", // Red
24
+ "#F97316", // Orange
25
+ "#F59E0B", // Amber
26
+ ];
27
+
28
+ export const ColorPicker = React.forwardRef<HTMLInputElement, ColorPickerProps>(
29
+ ({ label, error, isRequired, className, value, defaultValue, name, onChange, disabled, ...props }, ref) => {
30
+ const [color, setColor] = useState<string>((value as string) || (defaultValue as string) || "#6366F1");
31
+ const customInputRef = useRef<HTMLInputElement>(null);
32
+
33
+ // Sync with controlled value changes from props
34
+ useEffect(() => {
35
+ if (value !== undefined) {
36
+ setColor(value as string);
37
+ }
38
+ }, [value]);
39
+
40
+ const handleColorChange = (newColor: string) => {
41
+ if (disabled) return;
42
+
43
+ // Normalize hex color (ensure it starts with #)
44
+ const cleanColor = newColor.startsWith('#') ? newColor : '#' + newColor;
45
+
46
+ if (/^#[0-9A-F]{6}$/i.test(cleanColor)) {
47
+ setColor(cleanColor);
48
+ if (onChange) {
49
+ onChange({
50
+ target: {
51
+ name: name || '',
52
+ value: cleanColor,
53
+ }
54
+ });
55
+ }
56
+ } else {
57
+ setColor(newColor); // Keep the typing state
58
+ }
59
+ };
60
+
61
+ // Combine refs to intercept react-hook-form setting .value programmatically (like on reset() or initial mount)
62
+ const combinedRef = (node: HTMLInputElement | null) => {
63
+ if (typeof ref === 'function') {
64
+ ref(node);
65
+ } else if (ref) {
66
+ (ref as any).current = node;
67
+ }
68
+
69
+ if (node) {
70
+ // Set initial state from node value if set on mount
71
+ if (node.value && node.value !== color) {
72
+ setColor(node.value);
73
+ }
74
+
75
+ // Intercept programmatic setting of value property (e.g. from react-hook-form reset() or setValue())
76
+ if (!(node as any).__valueSetIntercepted) {
77
+ (node as any).__valueSetIntercepted = true;
78
+ const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
79
+ if (descriptor && descriptor.set) {
80
+ const originalSet = descriptor.set;
81
+ Object.defineProperty(node, 'value', {
82
+ get() {
83
+ return descriptor.get?.call(this);
84
+ },
85
+ set(val) {
86
+ originalSet.call(this, val);
87
+ setColor(val);
88
+ },
89
+ configurable: true
90
+ });
91
+ }
92
+ }
93
+ }
94
+ };
95
+
96
+ const isPreset = PRESET_COLORS.some(c => c.toLowerCase() === color.toLowerCase());
97
+
98
+ return (
99
+ <div className={cn("flex flex-col gap-1.5 w-full", className)}>
100
+ {/* Hidden input for react-hook-form integration */}
101
+ <input
102
+ ref={combinedRef}
103
+ type="hidden"
104
+ name={name}
105
+ value={color}
106
+ {...props}
107
+ />
108
+
109
+ {label && (
110
+ <Label isRequired={isRequired}>
111
+ {label}
112
+ </Label>
113
+ )}
114
+ <div className="flex flex-col gap-3">
115
+ {/* Color Swatches Grid */}
116
+ <div className="flex flex-wrap items-center gap-2.5">
117
+ {PRESET_COLORS.map((presetColor) => {
118
+ const isSelected = color.toLowerCase() === presetColor.toLowerCase();
119
+ return (
120
+ <button
121
+ key={presetColor}
122
+ type="button"
123
+ onClick={() => handleColorChange(presetColor)}
124
+ disabled={disabled}
125
+ className={cn(
126
+ "w-8 h-8 rounded-full cursor-pointer relative border border-gray-200/50 shadow-sm transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed",
127
+ isSelected ? "scale-105 ring-2 ring-primary-500 ring-offset-2" : "hover:shadow-md"
128
+ )}
129
+ style={{
130
+ backgroundColor: presetColor,
131
+ "--tw-ring-color": presetColor
132
+ } as React.CSSProperties}
133
+ title={presetColor}
134
+ >
135
+ {isSelected && (
136
+ <Check className="absolute inset-0 m-auto text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.5)]" size={14} strokeWidth={3} />
137
+ )}
138
+ </button>
139
+ );
140
+ })}
141
+
142
+ {/* Custom Color Button */}
143
+ <div className="relative">
144
+ <button
145
+ type="button"
146
+ onClick={() => customInputRef.current?.click()}
147
+ disabled={disabled}
148
+ className={cn(
149
+ "w-8 h-8 rounded-full border border-dashed border-gray-300 flex items-center justify-center cursor-pointer transition-all hover:scale-105 active:scale-95 shadow-sm disabled:opacity-50 disabled:cursor-not-allowed",
150
+ !isPreset ? "scale-105 ring-2 ring-offset-2 ring-primary-500 bg-white" : "hover:bg-gray-50 bg-white"
151
+ )}
152
+ style={{
153
+ "--tw-ring-color": !isPreset ? color : "transparent"
154
+ } as React.CSSProperties}
155
+ title="Custom Color Picker"
156
+ >
157
+ <span
158
+ className="absolute inset-0.5 rounded-full bg-gradient-to-tr from-rose-400 via-fuchsia-500 to-indigo-500 opacity-80"
159
+ style={!isPreset ? { backgroundColor: color, backgroundImage: 'none' } : undefined}
160
+ />
161
+ <Paintbrush className={cn("relative", !isPreset ? "text-white" : "text-gray-500")} size={13} strokeWidth={2.5} />
162
+ {!isPreset && (
163
+ <Check className="absolute inset-0 m-auto text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.5)]" size={14} strokeWidth={3} />
164
+ )}
165
+ </button>
166
+ {/* Hidden native color input */}
167
+ <input
168
+ ref={customInputRef}
169
+ type="color"
170
+ value={color}
171
+ onChange={(e) => handleColorChange(e.target.value)}
172
+ disabled={disabled}
173
+ className="absolute top-0 left-0 w-0 h-0 opacity-0 pointer-events-none"
174
+ />
175
+ </div>
176
+ </div>
177
+
178
+ {/* Manual Hex Input */}
179
+ <div className="flex items-center gap-2">
180
+ <div
181
+ className="w-5 h-5 rounded-md border border-gray-200 shadow-sm transition-colors"
182
+ style={{ backgroundColor: /^#[0-9A-F]{6}$/i.test(color) ? color : "#6366F1" }}
183
+ />
184
+ <div className="relative flex items-center w-28">
185
+ <span className="absolute left-2.5 text-xs font-semibold font-mono text-gray-400 select-none">#</span>
186
+ <input
187
+ type="text"
188
+ value={color.replace('#', '').toUpperCase()}
189
+ onChange={(e) => handleColorChange(e.target.value)}
190
+ disabled={disabled}
191
+ maxLength={6}
192
+ placeholder="FFFFFF"
193
+ className="w-full bg-surface-0 border border-border-default rounded-lg pl-6 pr-2.5 py-1.5 text-xs font-mono text-gray-700 outline-none transition-all hover:border-gray-300 focus:border-primary-500 focus:bg-surface-1 uppercase disabled:opacity-50 disabled:cursor-not-allowed"
194
+ />
195
+ </div>
196
+ </div>
197
+ </div>
198
+ {error && <span className="text-[11px] text-danger-alt font-medium">{error}</span>}
199
+ </div>
200
+ );
201
+ }
202
+ );
203
+
204
+ ColorPicker.displayName = 'ColorPicker';
@@ -20,10 +20,18 @@ export interface ModalProps {
20
20
  zIndex?: number;
21
21
  }
22
22
 
23
- export const Modal = ({ isOpen, onClose, title, children, className, footer, backdrop = 'opaque', size = 'sm', position = 'center', zIndex }: ModalProps) => {
23
+ export const Modal = ({ isOpen, onClose, title, children, className, footer, backdrop = 'opaque', size = 'sm', position = 'bottom', zIndex }: ModalProps) => {
24
24
  // SSR-safe portal mount guard — document is only available on the client
25
25
  const [mounted, setMounted] = useState(false);
26
- useEffect(() => { setMounted(true); }, []);
26
+ const [isDesktop, setIsDesktop] = useState(true);
27
+
28
+ useEffect(() => {
29
+ setMounted(true);
30
+ const checkDesktop = () => setIsDesktop(window.innerWidth >= 640);
31
+ checkDesktop();
32
+ window.addEventListener('resize', checkDesktop);
33
+ return () => window.removeEventListener('resize', checkDesktop);
34
+ }, []);
27
35
 
28
36
  useEffect(() => {
29
37
  if (isOpen) {
@@ -51,12 +59,14 @@ export const Modal = ({ isOpen, onClose, title, children, className, footer, bac
51
59
  full: 'max-w-[100vw]',
52
60
  };
53
61
 
62
+ const effectivePosition = position === 'bottom' && isDesktop ? 'center' : position;
63
+
54
64
  const content = (
55
65
  <AnimatePresence>
56
66
  {isOpen && (
57
67
  <div className={cn(
58
68
  "fixed inset-0 flex",
59
- position === 'center' ? "items-center justify-center p-2 sm:p-4" : "items-end justify-center sm:items-center p-0 sm:p-4",
69
+ effectivePosition === 'center' ? "items-center justify-center p-2 sm:p-4" : "items-end justify-center sm:items-center p-0 sm:p-4",
60
70
  size === 'full' ? 'p-0 sm:p-0' : ''
61
71
  )} style={{ zIndex: zIndex ?? 50 }}>
62
72
  <motion.div
@@ -67,13 +77,13 @@ export const Modal = ({ isOpen, onClose, title, children, className, footer, bac
67
77
  className={backdropClasses}
68
78
  />
69
79
  <motion.div
70
- initial={position === 'bottom' ? { opacity: 0, y: "100%" } : { opacity: 0, scale: 0.95, y: 20 }}
71
- animate={position === 'bottom' ? { opacity: 1, y: 0 } : { opacity: 1, scale: 1, y: 0 }}
72
- exit={position === 'bottom' ? { opacity: 0, y: "100%" } : { opacity: 0, scale: 0.95, y: 20 }}
80
+ initial={effectivePosition === 'bottom' ? { opacity: 0, y: "100%" } : { opacity: 0, scale: 0.95, y: 20 }}
81
+ animate={effectivePosition === 'bottom' ? { opacity: 1, y: 0 } : { opacity: 1, scale: 1, y: 0 }}
82
+ exit={effectivePosition === 'bottom' ? { opacity: 0, y: "100%" } : { opacity: 0, scale: 0.95, y: 20 }}
73
83
  transition={{ type: "spring", damping: 25, stiffness: 300 }}
74
84
  className={cn(
75
85
  "relative w-full bg-surface-1 shadow-[0_8px_40px_rgba(0,0,0,0.2)] flex flex-col overflow-hidden",
76
- size === 'full' ? "rounded-none h-screen max-h-screen" : position === 'bottom' ? "rounded-t-[24px] sm:rounded-[24px] max-h-[90vh]" : "rounded-[16px] sm:rounded-[24px] max-h-[90vh]",
86
+ size === 'full' ? "rounded-none h-screen max-h-screen" : effectivePosition === 'bottom' ? "rounded-t-[24px] sm:rounded-[24px] max-h-[90vh]" : "rounded-[16px] sm:rounded-[24px] max-h-[90vh]",
77
87
  sizeClasses[size],
78
88
  className
79
89
  )}
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ import { Paperclip } from "lucide-react";
4
+ import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
5
+
6
+ export interface RichEmailEditorHandle {
7
+ insertToken: (token: string) => void;
8
+ }
9
+
10
+ export interface RichEmailEditorProps {
11
+ value: string;
12
+ onChange: (value: string) => void;
13
+ minHeightClassName?: string;
14
+ onAttachFiles?: (files: File[]) => void;
15
+ attachmentAccept?: string;
16
+ }
17
+
18
+ const commands = [
19
+ ["bold", "B"],
20
+ ["italic", "I"],
21
+ ["underline", "U"],
22
+ ["insertOrderedList", "1."],
23
+ ["insertUnorderedList", "•"],
24
+ ] as const;
25
+
26
+ export const RichEmailEditor = forwardRef<RichEmailEditorHandle, RichEmailEditorProps>(
27
+ function RichEmailEditor({
28
+ value,
29
+ onChange,
30
+ minHeightClassName = "min-h-56",
31
+ onAttachFiles,
32
+ attachmentAccept,
33
+ }, ref) {
34
+ const editorRef = useRef<HTMLDivElement>(null);
35
+ const savedRange = useRef<Range | null>(null);
36
+
37
+ useEffect(() => {
38
+ if (editorRef.current && editorRef.current.innerHTML !== value) {
39
+ editorRef.current.innerHTML = value;
40
+ }
41
+ }, [value]);
42
+
43
+ const rememberSelection = () => {
44
+ const selection = window.getSelection();
45
+ if (selection?.rangeCount && editorRef.current?.contains(selection.anchorNode)) {
46
+ savedRange.current = selection.getRangeAt(0).cloneRange();
47
+ }
48
+ };
49
+
50
+ const runCommand = (command: string, commandValue?: string) => {
51
+ editorRef.current?.focus();
52
+ const selection = window.getSelection();
53
+ if (selection && savedRange.current) {
54
+ selection.removeAllRanges();
55
+ selection.addRange(savedRange.current);
56
+ }
57
+ document.execCommand(command, false, commandValue);
58
+ rememberSelection();
59
+ onChange(editorRef.current?.innerHTML ?? "");
60
+ };
61
+
62
+ useImperativeHandle(ref, () => ({
63
+ insertToken(token: string) {
64
+ editorRef.current?.focus();
65
+ const selection = window.getSelection();
66
+ if (selection && savedRange.current) {
67
+ selection.removeAllRanges();
68
+ selection.addRange(savedRange.current);
69
+ }
70
+ document.execCommand("insertText", false, token);
71
+ rememberSelection();
72
+ onChange(editorRef.current?.innerHTML ?? "");
73
+ },
74
+ }));
75
+
76
+ return (
77
+ <div className="overflow-hidden rounded-[10px] border-[1.5px] border-border-subtle bg-white focus-within:border-primary-400">
78
+ <div className="flex flex-wrap items-center gap-1 border-b border-gray-100 bg-gray-50 px-2 py-1.5">
79
+ <select aria-label="Paragraph style" onChange={(event) => runCommand("formatBlock", event.target.value)} className="rounded border border-gray-200 bg-white px-2 py-1 text-xs" defaultValue="p">
80
+ <option value="p">Normal</option>
81
+ <option value="h2">Heading</option>
82
+ <option value="h3">Subheading</option>
83
+ </select>
84
+ <select aria-label="Font size" onChange={(event) => runCommand("fontSize", event.target.value)} className="rounded border border-gray-200 bg-white px-2 py-1 text-xs" defaultValue="3">
85
+ <option value="2">10px</option>
86
+ <option value="3">12px</option>
87
+ <option value="4">14px</option>
88
+ <option value="5">18px</option>
89
+ </select>
90
+ {commands.map(([command, label]) => (
91
+ <button key={command} type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand(command)} className="rounded px-2 py-1 text-xs font-semibold text-gray-600 hover:bg-gray-200">
92
+ {label}
93
+ </button>
94
+ ))}
95
+ <label title="Text color" className="flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs text-gray-600 hover:bg-gray-200">
96
+ <span>A</span>
97
+ <input type="color" aria-label="Text color" onMouseDown={rememberSelection} onChange={(event) => runCommand("foreColor", event.target.value)} className="h-5 w-5 cursor-pointer border-0 bg-transparent p-0" />
98
+ </label>
99
+ <label title="Highlight color" className="flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs text-gray-600 hover:bg-gray-200">
100
+ <span>Highlight</span>
101
+ <input type="color" aria-label="Highlight color" onMouseDown={rememberSelection} onChange={(event) => runCommand("hiliteColor", event.target.value)} className="h-5 w-5 cursor-pointer border-0 bg-transparent p-0" />
102
+ </label>
103
+ {onAttachFiles && (
104
+ <label title="Attach documents" className="flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs text-gray-600 hover:bg-gray-200">
105
+ <Paperclip size={14} />
106
+ <span>Attach</span>
107
+ <input
108
+ type="file"
109
+ multiple
110
+ accept={attachmentAccept}
111
+ className="sr-only"
112
+ onChange={(event) => {
113
+ onAttachFiles(Array.from(event.target.files ?? []));
114
+ event.target.value = "";
115
+ }}
116
+ />
117
+ </label>
118
+ )}
119
+ </div>
120
+ <div
121
+ ref={editorRef}
122
+ contentEditable
123
+ suppressContentEditableWarning
124
+ role="textbox"
125
+ aria-multiline="true"
126
+ onInput={() => onChange(editorRef.current?.innerHTML ?? "")}
127
+ onKeyUp={rememberSelection}
128
+ onMouseUp={rememberSelection}
129
+ onBlur={rememberSelection}
130
+ className={`${minHeightClassName} p-3 text-sm outline-none [&_ol]:list-decimal [&_ol]:pl-6 [&_ul]:list-disc [&_ul]:pl-6 [&_li]:my-0.5`}
131
+ />
132
+ </div>
133
+ );
134
+ },
135
+ );
136
+
137
+ RichEmailEditor.displayName = "RichEmailEditor";
@@ -193,7 +193,23 @@ export function DashboardLayout({
193
193
  key={menu.id}
194
194
  onClick={() => {
195
195
  setActiveMenuId(menu.id);
196
- setIsSubSidebarOpen(true);
196
+ if (menu.groups?.length === 1 && menu.groups[0].items?.length === 1) {
197
+ const firstItemPath = menu.groups[0].items[0].path;
198
+ const isInternal = basePath
199
+ ? firstItemPath.startsWith(basePath)
200
+ : !externalPaths.some(path => firstItemPath.startsWith(path));
201
+ const href = basePath && isInternal
202
+ ? firstItemPath.replace(basePath, "") || "/"
203
+ : firstItemPath;
204
+
205
+ if (isInternal) {
206
+ router.push(href);
207
+ } else {
208
+ window.location.href = href;
209
+ }
210
+ } else {
211
+ setIsSubSidebarOpen(true);
212
+ }
197
213
  }}
198
214
  className={`flex flex-col items-center justify-center gap-1.5 cursor-pointer transition-colors w-full px-1 ${isActive ? "text-[#2D3142]" : "text-gray-400 hover:text-[#2D3142]"
199
215
  }`}