@apptimate/ui 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apptimate/ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"main": "src/index.tsx",
|
|
5
5
|
"types": "src/index.tsx",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@apptimate/core-lib": "*",
|
|
8
|
+
"apexcharts": "^5.15.2",
|
|
8
9
|
"class-variance-authority": "^0.7.1",
|
|
9
10
|
"clsx": "^2.1.1",
|
|
10
11
|
"framer-motion": "^12.38.0",
|
|
11
12
|
"lucide-react": "^1.14.0",
|
|
12
13
|
"react": "^19.2.5",
|
|
14
|
+
"react-apexcharts": "^2.1.1",
|
|
13
15
|
"recharts": "^2.15.4",
|
|
14
|
-
"tailwind-merge": "^3.5.0"
|
|
15
|
-
"apexcharts": "^5.11.0",
|
|
16
|
-
"react-apexcharts": "^2.1.0"
|
|
16
|
+
"tailwind-merge": "^3.5.0"
|
|
17
17
|
},
|
|
18
18
|
"publishConfig": {
|
|
19
19
|
"access": "public"
|
|
@@ -21,4 +21,4 @@
|
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"next": "^16.0.0"
|
|
23
23
|
}
|
|
24
|
-
}
|
|
24
|
+
}
|
|
@@ -6,9 +6,10 @@ import { Info } from 'lucide-react';
|
|
|
6
6
|
|
|
7
7
|
interface HintIconProps {
|
|
8
8
|
text: string;
|
|
9
|
+
icon?: React.ReactNode;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
export function HintIcon({ text }: HintIconProps) {
|
|
12
|
+
export function HintIcon({ text, icon }: HintIconProps) {
|
|
12
13
|
const [show, setShow] = useState(false);
|
|
13
14
|
const [pos, setPos] = useState({ top: 0, left: 0, isBelow: false });
|
|
14
15
|
const iconRef = React.useRef<HTMLSpanElement>(null);
|
|
@@ -40,7 +41,7 @@ export function HintIcon({ text }: HintIconProps) {
|
|
|
40
41
|
onMouseEnter={handleMouseEnter}
|
|
41
42
|
onMouseLeave={() => setShow(false)}
|
|
42
43
|
>
|
|
43
|
-
<Info size={14} className="text-foreground-muted hover:text-foreground-1 transition-colors cursor-help" />
|
|
44
|
+
{icon ? icon : <Info size={14} className="text-foreground-muted hover:text-foreground-1 transition-colors cursor-help" />}
|
|
44
45
|
</span>
|
|
45
46
|
{show && typeof document !== 'undefined' && ReactDOM.createPortal(
|
|
46
47
|
<div
|
|
@@ -19,6 +19,8 @@ export type DashboardMenuConfig = {
|
|
|
19
19
|
id: string;
|
|
20
20
|
label: string;
|
|
21
21
|
path: string;
|
|
22
|
+
icon?: React.ReactNode;
|
|
23
|
+
badge?: React.ReactNode;
|
|
22
24
|
}[]
|
|
23
25
|
}[]
|
|
24
26
|
};
|
|
@@ -47,7 +49,8 @@ export function DashboardLayout({
|
|
|
47
49
|
onProfile,
|
|
48
50
|
organizations = [],
|
|
49
51
|
selectedOrganization,
|
|
50
|
-
onOrganizationChange
|
|
52
|
+
onOrganizationChange,
|
|
53
|
+
extraHeaderContent
|
|
51
54
|
}: {
|
|
52
55
|
children: React.ReactNode;
|
|
53
56
|
logo?: React.ReactNode;
|
|
@@ -60,6 +63,7 @@ export function DashboardLayout({
|
|
|
60
63
|
organizations?: OrganizationConfig[];
|
|
61
64
|
selectedOrganization?: OrganizationConfig | null;
|
|
62
65
|
onOrganizationChange?: (org: OrganizationConfig) => void;
|
|
66
|
+
extraHeaderContent?: React.ReactNode;
|
|
63
67
|
}) {
|
|
64
68
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
65
69
|
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
|
|
@@ -178,7 +182,9 @@ export function DashboardLayout({
|
|
|
178
182
|
<div className="flex-1 overflow-y-auto px-4 space-y-6">
|
|
179
183
|
{activeMenu.groups.map((group) => (
|
|
180
184
|
<div key={group.id}>
|
|
181
|
-
|
|
185
|
+
{group.label && (
|
|
186
|
+
<h3 className="px-2 text-xs font-bold text-gray-400 uppercase tracking-wider mb-2 empty:hidden">{group.label}</h3>
|
|
187
|
+
)}
|
|
182
188
|
<nav className="flex flex-col gap-1">
|
|
183
189
|
{group.items.map((item) => {
|
|
184
190
|
// Find the longest matching path in this group to avoid parent paths being active
|
|
@@ -191,18 +197,28 @@ export function DashboardLayout({
|
|
|
191
197
|
: !externalPaths.some(ext => item.path.startsWith(ext));
|
|
192
198
|
const href = basePath && isInternal ? item.path.replace(basePath, "") || "/" : item.path;
|
|
193
199
|
|
|
194
|
-
const className = `px-3 py-2 rounded-lg text-sm transition-colors
|
|
200
|
+
const className = `px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between ${isItemActive
|
|
195
201
|
? "bg-[#F4F5F7] text-[#2D3142] font-semibold"
|
|
196
202
|
: "text-gray-500 font-medium hover:bg-gray-50"
|
|
197
203
|
}`;
|
|
198
204
|
|
|
205
|
+
const content = (
|
|
206
|
+
<>
|
|
207
|
+
<div className="flex items-center gap-3">
|
|
208
|
+
{item.icon && <span className={`${isItemActive ? 'text-[#2D3142]' : 'text-gray-400'}`}>{item.icon}</span>}
|
|
209
|
+
<span>{item.label}</span>
|
|
210
|
+
</div>
|
|
211
|
+
{item.badge && <div>{item.badge}</div>}
|
|
212
|
+
</>
|
|
213
|
+
);
|
|
214
|
+
|
|
199
215
|
return isInternal ? (
|
|
200
216
|
<Link key={item.id} href={href} className={className}>
|
|
201
|
-
{
|
|
217
|
+
{content}
|
|
202
218
|
</Link>
|
|
203
219
|
) : (
|
|
204
220
|
<a key={item.id} href={href} className={className}>
|
|
205
|
-
{
|
|
221
|
+
{content}
|
|
206
222
|
</a>
|
|
207
223
|
);
|
|
208
224
|
})}
|
|
@@ -469,7 +485,8 @@ export function DashboardLayout({
|
|
|
469
485
|
</div>
|
|
470
486
|
</div>
|
|
471
487
|
|
|
472
|
-
<style dangerouslySetInnerHTML={{
|
|
488
|
+
<style dangerouslySetInnerHTML={{
|
|
489
|
+
__html: `
|
|
473
490
|
@keyframes orgModalIn {
|
|
474
491
|
from {
|
|
475
492
|
opacity: 0;
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|
4
4
|
import { cn } from "@apptimate/core-lib";
|
|
5
|
-
import { CreditCard, Banknote, Building, Trash2, Loader2, Split, Check, AlertCircle } from "lucide-react";
|
|
5
|
+
import { CreditCard, Banknote, Building, Trash2, Loader2, Split, Check, AlertCircle, AlertTriangle, CheckCircle } from "lucide-react";
|
|
6
|
+
import { HintIcon } from "../base-components/HintIcon";
|
|
6
7
|
|
|
7
8
|
/* ── Types (mirroring sales POS types) ── */
|
|
8
9
|
|
|
@@ -61,11 +62,13 @@ export interface PaymentSectionProps {
|
|
|
61
62
|
materialTypes?: any[];
|
|
62
63
|
/** Special mode codes that are allowed (e.g. 'credit-gold') */
|
|
63
64
|
allowedSpecialModes?: string[];
|
|
65
|
+
/** Fetcher for cheque leaves (for auto-complete) */
|
|
66
|
+
fetchChequeLeaves?: (query: string) => Promise<{ is_success: boolean; result?: any[] }>;
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
/* ── Component ── */
|
|
67
70
|
|
|
68
|
-
export function PaymentSection({ totalAmount, payments, onPaymentsChange, compact, fetchPaymentModes, fetchBankAccounts, materialTypes = [], allowedSpecialModes = [] }: PaymentSectionProps) {
|
|
71
|
+
export function PaymentSection({ totalAmount, payments, onPaymentsChange, compact, fetchPaymentModes, fetchBankAccounts, materialTypes = [], allowedSpecialModes = [], fetchChequeLeaves }: PaymentSectionProps) {
|
|
69
72
|
const [paymentModes, setPaymentModes] = useState<PaymentMode[]>([]);
|
|
70
73
|
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
|
|
71
74
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -241,7 +244,7 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
|
|
|
241
244
|
{/* ── Single Mode: Entry with fields (no delete) ── */}
|
|
242
245
|
{selectionMode === "single" && payments.length === 1 && (
|
|
243
246
|
<PaymentEntryRow entry={payments[0]} mode={paymentModes.find((m) => m.id === payments[0].mode_id)}
|
|
244
|
-
bankAccounts={bankAccounts} materialTypes={materialTypes} onUpdate={(updates) => updatePayment(0, updates)} onRemove={() => {}} showRemove={false} />
|
|
247
|
+
bankAccounts={bankAccounts} materialTypes={materialTypes} onUpdate={(updates) => updatePayment(0, updates)} onRemove={() => {}} showRemove={false} fetchChequeLeaves={fetchChequeLeaves} />
|
|
245
248
|
)}
|
|
246
249
|
|
|
247
250
|
{/* ── Split Mode: Multi-entry UI ── */}
|
|
@@ -251,7 +254,7 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
|
|
|
251
254
|
const mode = paymentModes.find((m) => m.id === entry.mode_id);
|
|
252
255
|
return (
|
|
253
256
|
<PaymentEntryRow key={idx} entry={entry} mode={mode} bankAccounts={bankAccounts} materialTypes={materialTypes}
|
|
254
|
-
onUpdate={(updates) => updatePayment(idx, updates)} onRemove={() => removePayment(idx)} />
|
|
257
|
+
onUpdate={(updates) => updatePayment(idx, updates)} onRemove={() => removePayment(idx)} fetchChequeLeaves={fetchChequeLeaves} />
|
|
255
258
|
);
|
|
256
259
|
})}
|
|
257
260
|
{/* Add split mode buttons */}
|
|
@@ -325,9 +328,10 @@ interface PaymentEntryRowProps {
|
|
|
325
328
|
onUpdate: (updates: Partial<PaymentEntry>) => void;
|
|
326
329
|
onRemove: () => void;
|
|
327
330
|
showRemove?: boolean;
|
|
331
|
+
fetchChequeLeaves?: (query: string) => Promise<{ is_success: boolean; result?: any[] }>;
|
|
328
332
|
}
|
|
329
333
|
|
|
330
|
-
function PaymentEntryRow({ entry, mode, bankAccounts, materialTypes = [], onUpdate, onRemove, showRemove = true }: PaymentEntryRowProps) {
|
|
334
|
+
function PaymentEntryRow({ entry, mode, bankAccounts, materialTypes = [], onUpdate, onRemove, showRemove = true, fetchChequeLeaves }: PaymentEntryRowProps) {
|
|
331
335
|
const fields = mode?.field_config?.fields || [];
|
|
332
336
|
|
|
333
337
|
const getModeIcon = () => {
|
|
@@ -369,9 +373,24 @@ function PaymentEntryRow({ entry, mode, bankAccounts, materialTypes = [], onUpda
|
|
|
369
373
|
<div className="grid grid-cols-2 gap-2.5">
|
|
370
374
|
{fields.map((field: PaymentModeField) => (
|
|
371
375
|
<div key={field.key} className="flex flex-col gap-1">
|
|
372
|
-
<label className="text-[11px] text-foreground-subtle font-bold uppercase tracking-wider">
|
|
376
|
+
<label className="text-[11px] text-foreground-subtle font-bold uppercase tracking-wider flex items-center">
|
|
373
377
|
{field.label}
|
|
374
378
|
{field.required && <span className="text-danger-alt ml-0.5">*</span>}
|
|
379
|
+
{field.key === "cheque_number" && fetchChequeLeaves && !!entry.metadata[field.key] && (
|
|
380
|
+
<span className="ml-1.5 flex items-center">
|
|
381
|
+
{entry.metadata.cheque_leaf_id ? (
|
|
382
|
+
<HintIcon
|
|
383
|
+
text="Selected from your cheque book. This will be automatically issued."
|
|
384
|
+
icon={<CheckCircle size={13} className="text-green-500" />}
|
|
385
|
+
/>
|
|
386
|
+
) : (
|
|
387
|
+
<HintIcon
|
|
388
|
+
text="Not selected from cheque book. This cheque will not be auto-issued."
|
|
389
|
+
icon={<AlertTriangle size={13} className="text-amber-500" />}
|
|
390
|
+
/>
|
|
391
|
+
)}
|
|
392
|
+
</span>
|
|
393
|
+
)}
|
|
375
394
|
</label>
|
|
376
395
|
{field.type === "bank_account_select" ? (
|
|
377
396
|
<select value={entry.metadata[field.key] || ""}
|
|
@@ -389,6 +408,21 @@ function PaymentEntryRow({ entry, mode, bankAccounts, materialTypes = [], onUpda
|
|
|
389
408
|
<option value="">Select Material...</option>
|
|
390
409
|
{materialTypes.map((m) => (<option key={m.id} value={m.id}>{m.name}</option>))}
|
|
391
410
|
</select>
|
|
411
|
+
) : field.key === "cheque_number" && fetchChequeLeaves ? (
|
|
412
|
+
<ChequeAutocomplete
|
|
413
|
+
value={String(entry.metadata[field.key] || "")}
|
|
414
|
+
onChange={(val, leafId, suggestion) => {
|
|
415
|
+
const newMetadata = { ...entry.metadata, [field.key]: val, cheque_leaf_id: leafId || "" };
|
|
416
|
+
if (suggestion?.cheque_book?.bank_account) {
|
|
417
|
+
const ba = suggestion.cheque_book.bank_account;
|
|
418
|
+
if (ba.bank_name) newMetadata.bank_name = ba.bank_name;
|
|
419
|
+
if (ba.branch) newMetadata.branch = ba.branch;
|
|
420
|
+
}
|
|
421
|
+
onUpdate({ metadata: newMetadata });
|
|
422
|
+
}}
|
|
423
|
+
fetchChequeLeaves={fetchChequeLeaves}
|
|
424
|
+
readonly={field.readonly}
|
|
425
|
+
/>
|
|
392
426
|
) : (
|
|
393
427
|
<input type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
|
|
394
428
|
{...(field.type === "number" ? { step: "any" } : {})}
|
|
@@ -404,3 +438,109 @@ function PaymentEntryRow({ entry, mode, bankAccounts, materialTypes = [], onUpda
|
|
|
404
438
|
</div>
|
|
405
439
|
);
|
|
406
440
|
}
|
|
441
|
+
|
|
442
|
+
/* ── ChequeAutocomplete ── */
|
|
443
|
+
|
|
444
|
+
function ChequeAutocomplete({
|
|
445
|
+
value,
|
|
446
|
+
onChange,
|
|
447
|
+
fetchChequeLeaves,
|
|
448
|
+
readonly
|
|
449
|
+
}: {
|
|
450
|
+
value: string;
|
|
451
|
+
onChange: (val: string, leafId?: number, suggestion?: any) => void;
|
|
452
|
+
fetchChequeLeaves: (q: string) => Promise<{ is_success: boolean; result?: any[] }>;
|
|
453
|
+
readonly?: boolean;
|
|
454
|
+
}) {
|
|
455
|
+
const [query, setQuery] = useState(value);
|
|
456
|
+
const [suggestions, setSuggestions] = useState<any[]>([]);
|
|
457
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
458
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
459
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
460
|
+
|
|
461
|
+
useEffect(() => {
|
|
462
|
+
setQuery(value);
|
|
463
|
+
}, [value]);
|
|
464
|
+
|
|
465
|
+
useEffect(() => {
|
|
466
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
467
|
+
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
|
468
|
+
setIsOpen(false);
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
472
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
473
|
+
}, []);
|
|
474
|
+
|
|
475
|
+
const fetchSuggestions = useCallback(async (search: string) => {
|
|
476
|
+
setIsLoading(true);
|
|
477
|
+
try {
|
|
478
|
+
const res = await fetchChequeLeaves(search);
|
|
479
|
+
if (res.is_success && res.result) {
|
|
480
|
+
setSuggestions(res.result);
|
|
481
|
+
setIsOpen(true);
|
|
482
|
+
}
|
|
483
|
+
} catch (e) {
|
|
484
|
+
console.error(e);
|
|
485
|
+
} finally {
|
|
486
|
+
setIsLoading(false);
|
|
487
|
+
}
|
|
488
|
+
}, [fetchChequeLeaves]);
|
|
489
|
+
|
|
490
|
+
useEffect(() => {
|
|
491
|
+
if (!isOpen) return;
|
|
492
|
+
const timer = setTimeout(() => {
|
|
493
|
+
fetchSuggestions(query);
|
|
494
|
+
}, 300);
|
|
495
|
+
return () => clearTimeout(timer);
|
|
496
|
+
}, [query, isOpen, fetchSuggestions]);
|
|
497
|
+
|
|
498
|
+
return (
|
|
499
|
+
<div ref={wrapperRef} className="relative">
|
|
500
|
+
<input
|
|
501
|
+
type="text"
|
|
502
|
+
value={query}
|
|
503
|
+
onChange={(e) => {
|
|
504
|
+
setQuery(e.target.value);
|
|
505
|
+
onChange(e.target.value, undefined, undefined); // reset ID when typed manually
|
|
506
|
+
setIsOpen(true);
|
|
507
|
+
}}
|
|
508
|
+
onFocus={() => {
|
|
509
|
+
if (!readonly) {
|
|
510
|
+
setIsOpen(true);
|
|
511
|
+
fetchSuggestions(query);
|
|
512
|
+
}
|
|
513
|
+
}}
|
|
514
|
+
readOnly={readonly}
|
|
515
|
+
className={cn("w-full bg-surface-1 border-[1.5px] border-border-subtle rounded-[8px] px-2.5 py-2.5 text-[12px] text-foreground-1 outline-none focus:border-primary/40 transition-all", readonly && "opacity-60 bg-surface-0 cursor-not-allowed border-border")}
|
|
516
|
+
placeholder="Type to search cheques..."
|
|
517
|
+
/>
|
|
518
|
+
{isLoading && (
|
|
519
|
+
<div className="absolute right-2 top-2.5">
|
|
520
|
+
<Loader2 size={14} className="animate-spin text-foreground-subtle" />
|
|
521
|
+
</div>
|
|
522
|
+
)}
|
|
523
|
+
{isOpen && suggestions.length > 0 && !readonly && (
|
|
524
|
+
<div className="absolute z-50 mt-1 w-full bg-surface-0 border border-border-subtle rounded-[8px] shadow-lg max-h-48 overflow-y-auto">
|
|
525
|
+
{suggestions.map((s) => (
|
|
526
|
+
<div
|
|
527
|
+
key={s.id}
|
|
528
|
+
className="px-3 py-2 text-[12px] text-foreground-1 hover:bg-primary/5 hover:text-primary cursor-pointer transition-colors"
|
|
529
|
+
onClick={() => {
|
|
530
|
+
setQuery(s.cheque_number);
|
|
531
|
+
onChange(s.cheque_number, s.id, s);
|
|
532
|
+
setIsOpen(false);
|
|
533
|
+
}}
|
|
534
|
+
>
|
|
535
|
+
<div className="font-semibold">{s.cheque_number}</div>
|
|
536
|
+
<div className="text-[10px] text-foreground-subtle flex items-center gap-2">
|
|
537
|
+
{s.cheque_book?.bank_account?.bank_name && <span>{s.cheque_book.bank_account.bank_name}</span>}
|
|
538
|
+
{s.amount && <span>Amount: {s.amount}</span>}
|
|
539
|
+
</div>
|
|
540
|
+
</div>
|
|
541
|
+
))}
|
|
542
|
+
</div>
|
|
543
|
+
)}
|
|
544
|
+
</div>
|
|
545
|
+
);
|
|
546
|
+
}
|
|
@@ -48,7 +48,8 @@ export interface BarChartProps {
|
|
|
48
48
|
colors?: string[];
|
|
49
49
|
horizontal?: boolean;
|
|
50
50
|
stacked?: boolean;
|
|
51
|
-
|
|
51
|
+
xFormatter?: (val: number) => string;
|
|
52
|
+
yFormatter?: (val: number | string) => string;
|
|
52
53
|
tooltipYFormatter?: (val: number) => string;
|
|
53
54
|
showToolbar?: boolean;
|
|
54
55
|
showLegend?: boolean;
|
|
@@ -99,7 +100,7 @@ export function AreaChart({
|
|
|
99
100
|
grid: defaultGrid,
|
|
100
101
|
tooltip: {
|
|
101
102
|
theme: "dark", shared: true, intersect: false,
|
|
102
|
-
|
|
103
|
+
...(tooltipXFormatter ? { x: { formatter: tooltipXFormatter } } : {}),
|
|
103
104
|
y: { formatter: tooltipYFormatter || yFormatter },
|
|
104
105
|
style: { fontSize: "12px" },
|
|
105
106
|
},
|
|
@@ -162,7 +163,7 @@ export function LineChart({
|
|
|
162
163
|
export function BarChart({
|
|
163
164
|
series, categories, height = 350, colors,
|
|
164
165
|
horizontal = false, stacked = false,
|
|
165
|
-
yFormatter = (v) => String(v),
|
|
166
|
+
xFormatter, yFormatter = (v) => String(v),
|
|
166
167
|
tooltipYFormatter,
|
|
167
168
|
showToolbar = false, showLegend = true,
|
|
168
169
|
borderRadius = 6, columnWidth = "50%", className = "",
|
|
@@ -179,7 +180,7 @@ export function BarChart({
|
|
|
179
180
|
dataLabels: { enabled: false },
|
|
180
181
|
xaxis: {
|
|
181
182
|
categories,
|
|
182
|
-
labels: { style: defaultAxisStyle },
|
|
183
|
+
labels: { style: defaultAxisStyle, formatter: xFormatter },
|
|
183
184
|
axisBorder: { show: false }, axisTicks: { show: false },
|
|
184
185
|
},
|
|
185
186
|
yaxis: { labels: { style: defaultAxisStyle, formatter: yFormatter } },
|
|
@@ -220,3 +221,55 @@ export function DonutChart({
|
|
|
220
221
|
</div>
|
|
221
222
|
);
|
|
222
223
|
}
|
|
224
|
+
|
|
225
|
+
// ── Heatmap Chart ────────────────────────────────────────────────────
|
|
226
|
+
export interface HeatmapChartProps {
|
|
227
|
+
series: { name: string; data: { x: string; y: number }[] }[];
|
|
228
|
+
height?: number;
|
|
229
|
+
colors?: string[];
|
|
230
|
+
className?: string;
|
|
231
|
+
tooltipYFormatter?: (val: number) => string;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function HeatmapChart({
|
|
235
|
+
series, height = 350, colors = ["#3D5A80"], className = "", tooltipYFormatter
|
|
236
|
+
}: HeatmapChartProps) {
|
|
237
|
+
const options = useMemo<ApexCharts.ApexOptions>(() => ({
|
|
238
|
+
chart: { type: "heatmap", height, fontFamily: defaultFont, ...defaultAnimations, toolbar: { show: false } },
|
|
239
|
+
colors,
|
|
240
|
+
plotOptions: {
|
|
241
|
+
heatmap: {
|
|
242
|
+
shadeIntensity: 0.5,
|
|
243
|
+
radius: 4,
|
|
244
|
+
useFillColorAsStroke: false,
|
|
245
|
+
colorScale: {
|
|
246
|
+
ranges: [
|
|
247
|
+
{ from: 0, to: 0, color: "#f1f5f9", name: "0" },
|
|
248
|
+
{ from: 1, to: 5, color: "#cbd5e1", name: "1-5" },
|
|
249
|
+
{ from: 6, to: 15, color: "#94a3b8", name: "6-15" },
|
|
250
|
+
{ from: 16, to: 50, color: "#64748b", name: "16-50" },
|
|
251
|
+
{ from: 51, to: 1000, color: colors[0], name: "50+" }
|
|
252
|
+
]
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
dataLabels: { enabled: false },
|
|
257
|
+
xaxis: {
|
|
258
|
+
labels: { style: defaultAxisStyle },
|
|
259
|
+
axisBorder: { show: false }, axisTicks: { show: false }
|
|
260
|
+
},
|
|
261
|
+
yaxis: { labels: { style: defaultAxisStyle } },
|
|
262
|
+
grid: defaultGrid,
|
|
263
|
+
tooltip: {
|
|
264
|
+
theme: "dark",
|
|
265
|
+
y: { formatter: tooltipYFormatter || ((val) => String(val)) },
|
|
266
|
+
style: { fontSize: "12px" },
|
|
267
|
+
},
|
|
268
|
+
}), [colors, height, tooltipYFormatter]);
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<div className={`w-full ${className}`}>
|
|
272
|
+
<ApexChart options={options} series={series} type="heatmap" height={height} width="100%" />
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
}
|