@apptimate/ui 5.1.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 +3 -2
- package/src/base-components/ChartOfAccountPicker.tsx +203 -0
- package/src/base-components/ColorPicker.tsx +204 -0
- package/src/base-components/Modal.tsx +17 -7
- package/src/base-components/RichEmailEditor.tsx +137 -0
- package/src/common-components/DashboardLayout.tsx +17 -1
- package/src/common-components/attendance-shifts/AttendanceTimesheets.tsx +43 -0
- package/src/common-components/attendance-shifts/CalendarLegend.tsx +27 -0
- package/src/common-components/attendance-shifts/CancelShiftModal.tsx +148 -0
- package/src/common-components/attendance-shifts/DailyOverview.tsx +198 -0
- package/src/common-components/attendance-shifts/DailySchedulePanel.tsx +139 -0
- package/src/common-components/attendance-shifts/ExchangeShiftModal.tsx +132 -0
- package/src/common-components/attendance-shifts/ImportExcelModal.tsx +183 -0
- package/src/common-components/attendance-shifts/ManualPunchModal.tsx +139 -0
- package/src/common-components/attendance-shifts/PendingActionsPanel.tsx +321 -0
- package/src/common-components/attendance-shifts/ShiftCalendar.tsx +451 -0
- package/src/common-components/attendance-shifts/ShiftTemplateForm.tsx +249 -0
- package/src/common-components/attendance-shifts/SummaryStrip.tsx +81 -0
- package/src/common-components/item-wizard/ItemFormWizard.tsx +8 -1
- package/src/common-components/item-wizard/SkuConfigModal.tsx +6 -1
- package/src/common-components/pickers/BrandPicker.tsx +5 -1
- package/src/common-components/pickers/CategoryPicker.tsx +5 -1
- package/src/common-components/pickers/EmployeePicker.tsx +120 -0
- package/src/common-components/pickers/EntityPickerModal.tsx +10 -5
- package/src/common-components/pickers/PartyPicker.tsx +5 -1
- package/src/common-components/pickers/UomGroupPicker.tsx +5 -1
- package/src/common-components/pickers/UomPicker.tsx +6 -1
- package/src/common-components/pickers/WarehousePicker.tsx +5 -1
- package/src/components/shared/CustomerSelectorComponent.tsx +7 -1
- package/src/components/shared/ImageUploadComponent.tsx +4 -1
- package/src/components/shared/PaymentModeComponent.tsx +6 -1
- package/src/components/shared/ProductSelectorComponent.tsx +7 -1
- package/src/index.tsx +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apptimate/ui",
|
|
3
|
-
"version": "5.
|
|
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 = '
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
71
|
-
animate={
|
|
72
|
-
exit={
|
|
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" :
|
|
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
|
-
|
|
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
|
}`}
|