@wakastellar/ui 2.0.0 → 2.1.1
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/README.md +71 -8
- package/dist/cli/commands/add.d.ts +7 -0
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/list.d.ts +5 -0
- package/dist/cli/commands/search.d.ts +1 -0
- package/dist/cli/index.cjs +6014 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/utils/config.d.ts +29 -0
- package/dist/cli/utils/logger.d.ts +20 -0
- package/dist/cli/utils/registry.d.ts +23 -0
- package/package.json +14 -3
- package/src/blocks/activity-timeline/index.tsx +586 -0
- package/src/blocks/calendar-view/index.tsx +756 -0
- package/src/blocks/chat/index.tsx +1018 -0
- package/src/blocks/chat/widget.tsx +504 -0
- package/src/blocks/dashboard/index.tsx +522 -0
- package/src/blocks/empty-states/index.tsx +452 -0
- package/src/blocks/error-pages/index.tsx +426 -0
- package/src/blocks/faq/index.tsx +479 -0
- package/src/blocks/file-manager/index.tsx +890 -0
- package/src/blocks/footer/index.tsx +133 -0
- package/src/blocks/header/index.tsx +357 -0
- package/src/blocks/headtab/index.tsx +139 -0
- package/src/blocks/i18n-editor/index.tsx +1016 -0
- package/src/blocks/index.ts +80 -0
- package/src/blocks/kanban-board/index.tsx +779 -0
- package/src/blocks/landing/index.tsx +677 -0
- package/src/blocks/language-selector/index.tsx +88 -0
- package/src/blocks/layout/index.tsx +159 -0
- package/src/blocks/login/index.tsx +339 -0
- package/src/blocks/login/types.ts +131 -0
- package/src/blocks/pricing/index.tsx +564 -0
- package/src/blocks/profile/index.tsx +746 -0
- package/src/blocks/settings/index.tsx +558 -0
- package/src/blocks/sidebar/index.tsx +713 -0
- package/src/blocks/theme-creator-block/index.tsx +835 -0
- package/src/blocks/user-management/index.tsx +1037 -0
- package/src/blocks/wizard/index.tsx +719 -0
- package/src/components/DataTable/DataTable.tsx +406 -0
- package/src/components/DataTable/DataTableAdvanced.tsx +720 -0
- package/src/components/DataTable/DataTableBody.tsx +216 -0
- package/src/components/DataTable/DataTableCell.tsx +172 -0
- package/src/components/DataTable/DataTableColumnResizer.tsx +62 -0
- package/src/components/DataTable/DataTableConflictResolver.tsx +478 -0
- package/src/components/DataTable/DataTableContextMenu.tsx +219 -0
- package/src/components/DataTable/DataTableEditCell.tsx +279 -0
- package/src/components/DataTable/DataTableFilterBuilder.tsx +519 -0
- package/src/components/DataTable/DataTableFilters.tsx +535 -0
- package/src/components/DataTable/DataTableGrouping.tsx +147 -0
- package/src/components/DataTable/DataTableHeader.tsx +172 -0
- package/src/components/DataTable/DataTablePagination.tsx +125 -0
- package/src/components/DataTable/DataTableSelection.tsx +269 -0
- package/src/components/DataTable/DataTableSyncStatus.tsx +281 -0
- package/src/components/DataTable/DataTableToolbar.tsx +262 -0
- package/src/components/DataTable/README.md +446 -0
- package/src/components/DataTable/__tests__/DataTableAdvanced.test.tsx +426 -0
- package/src/components/DataTable/__tests__/DataTableEdit.test.tsx +329 -0
- package/src/components/DataTable/__tests__/useDataTableAdvanced.test.ts +455 -0
- package/src/components/DataTable/examples/EditExample.tsx +166 -0
- package/src/components/DataTable/formatters/index.ts +335 -0
- package/src/components/DataTable/hooks/__tests__/useDataTableEdit.test.ts +239 -0
- package/src/components/DataTable/hooks/useDataTable.ts +145 -0
- package/src/components/DataTable/hooks/useDataTableAdvanced.ts +342 -0
- package/src/components/DataTable/hooks/useDataTableAdvancedFilters.ts +637 -0
- package/src/components/DataTable/hooks/useDataTableColumnTemplates.ts +186 -0
- package/src/components/DataTable/hooks/useDataTableEdit.ts +167 -0
- package/src/components/DataTable/hooks/useDataTableExport.ts +227 -0
- package/src/components/DataTable/hooks/useDataTableImport.ts +216 -0
- package/src/components/DataTable/hooks/useDataTableOffline.ts +481 -0
- package/src/components/DataTable/hooks/useDataTableTheme.ts +213 -0
- package/src/components/DataTable/hooks/useDataTableVirtualization.ts +99 -0
- package/src/components/DataTable/hooks/useTableLayout.ts +85 -0
- package/src/components/DataTable/index.ts +81 -0
- package/src/components/DataTable/services/IndexedDBService.ts +504 -0
- package/src/components/DataTable/templates/index.tsx +803 -0
- package/src/components/DataTable/types.ts +504 -0
- package/src/components/DataTable/utils.ts +164 -0
- package/src/components/DataTable/workers/exportWorker.ts +213 -0
- package/src/components/accordion/index.tsx +61 -0
- package/src/components/alert/index.tsx +61 -0
- package/src/components/alert-dialog/index.tsx +146 -0
- package/src/components/aspect-ratio/index.tsx +12 -0
- package/src/components/avatar/index.tsx +54 -0
- package/src/components/badge/Badge.stories.tsx +64 -0
- package/src/components/badge/index.tsx +38 -0
- package/src/components/button/Button.stories.tsx +173 -0
- package/src/components/button/index.tsx +56 -0
- package/src/components/calendar/index.tsx +73 -0
- package/src/components/card/index.tsx +78 -0
- package/src/components/checkbox/index.tsx +34 -0
- package/src/components/code/index.tsx +229 -0
- package/src/components/collapsible/index.tsx +16 -0
- package/src/components/command/index.tsx +162 -0
- package/src/components/context-menu/index.tsx +204 -0
- package/src/components/dialog/index.tsx +126 -0
- package/src/components/dropdown-menu/index.tsx +204 -0
- package/src/components/error-boundary/ErrorBoundary.tsx +281 -0
- package/src/components/error-boundary/index.ts +7 -0
- package/src/components/form/index.tsx +183 -0
- package/src/components/hover-card/index.tsx +33 -0
- package/src/components/index.ts +368 -0
- package/src/components/input/Input.stories.tsx +100 -0
- package/src/components/input/index.tsx +27 -0
- package/src/components/input-otp/index.tsx +277 -0
- package/src/components/label/index.tsx +30 -0
- package/src/components/language-selector/index.tsx +341 -0
- package/src/components/menubar/index.tsx +240 -0
- package/src/components/navigation-menu/index.tsx +134 -0
- package/src/components/popover/index.tsx +35 -0
- package/src/components/progress/index.tsx +32 -0
- package/src/components/radio-group/index.tsx +48 -0
- package/src/components/scroll-area/index.tsx +52 -0
- package/src/components/select/index.tsx +164 -0
- package/src/components/separator/index.tsx +35 -0
- package/src/components/sheet/index.tsx +147 -0
- package/src/components/skeleton/index.tsx +22 -0
- package/src/components/slider/index.tsx +32 -0
- package/src/components/switch/index.tsx +33 -0
- package/src/components/table/index.tsx +117 -0
- package/src/components/tabs/index.tsx +59 -0
- package/src/components/textarea/index.tsx +30 -0
- package/src/components/theme-selector/index.tsx +327 -0
- package/src/components/toast/index.tsx +133 -0
- package/src/components/toaster/index.tsx +34 -0
- package/src/components/toggle/index.tsx +49 -0
- package/src/components/tooltip/index.tsx +34 -0
- package/src/components/typography/index.tsx +276 -0
- package/src/components/waka-3d-pie-chart/index.tsx +486 -0
- package/src/components/waka-achievement-unlock/index.tsx +716 -0
- package/src/components/waka-activity-feed/index.tsx +686 -0
- package/src/components/waka-address-autocomplete/index.tsx +1202 -0
- package/src/components/waka-admincrumb/index.tsx +349 -0
- package/src/components/waka-alert-stack/index.tsx +827 -0
- package/src/components/waka-allocation-matrix/index.tsx +1278 -0
- package/src/components/waka-approval-chain/index.tsx +766 -0
- package/src/components/waka-audit-log/index.tsx +1475 -0
- package/src/components/waka-autocomplete/index.tsx +358 -0
- package/src/components/waka-badge-showcase/index.tsx +704 -0
- package/src/components/waka-barcode/index.tsx +260 -0
- package/src/components/waka-biometric-prompt/index.tsx +765 -0
- package/src/components/waka-bottom-sheet/index.tsx +495 -0
- package/src/components/waka-breadcrumb/index.tsx +376 -0
- package/src/components/waka-breadcrumb-path/index.tsx +513 -0
- package/src/components/waka-budget-burn/index.tsx +1234 -0
- package/src/components/waka-capacity-planner/index.tsx +1107 -0
- package/src/components/waka-carousel/index.tsx +893 -0
- package/src/components/waka-cart-summary/index.tsx +1055 -0
- package/src/components/waka-challenge-timer/index.tsx +1044 -0
- package/src/components/waka-charts/WakaAreaChart.tsx +251 -0
- package/src/components/waka-charts/WakaBarChart.tsx +222 -0
- package/src/components/waka-charts/WakaChart.tsx +124 -0
- package/src/components/waka-charts/WakaLineChart.tsx +219 -0
- package/src/components/waka-charts/WakaMiniChart.tsx +133 -0
- package/src/components/waka-charts/WakaPieChart.tsx +214 -0
- package/src/components/waka-charts/WakaSparkline.tsx +229 -0
- package/src/components/waka-charts/dataTableHelpers.ts +109 -0
- package/src/components/waka-charts/hooks/useChartTheme.ts +123 -0
- package/src/components/waka-charts/hooks/useRechartsLoader.ts +234 -0
- package/src/components/waka-charts/index.ts +90 -0
- package/src/components/waka-charts/types.ts +330 -0
- package/src/components/waka-chat-bubble/index.tsx +1060 -0
- package/src/components/waka-checklist/index.tsx +1067 -0
- package/src/components/waka-checkout-stepper/index.tsx +976 -0
- package/src/components/waka-cohort-table/index.tsx +1011 -0
- package/src/components/waka-color-picker/index.tsx +447 -0
- package/src/components/waka-combo-counter/index.tsx +864 -0
- package/src/components/waka-combobox/index.tsx +497 -0
- package/src/components/waka-command-bar/index.tsx +403 -0
- package/src/components/waka-compare-period/index.tsx +1230 -0
- package/src/components/waka-connection-matrix/index.tsx +1053 -0
- package/src/components/waka-contribution-graph/index.tsx +552 -0
- package/src/components/waka-cost-breakdown/index.tsx +1065 -0
- package/src/components/waka-coupon-input/index.tsx +592 -0
- package/src/components/waka-credit-card-input/index.tsx +982 -0
- package/src/components/waka-daily-reward/index.tsx +762 -0
- package/src/components/waka-date-range-picker/index.tsx +378 -0
- package/src/components/waka-datetime-picker/index.tsx +793 -0
- package/src/components/waka-datetime-picker.form-integration/index.tsx +402 -0
- package/src/components/waka-deployment-lane/index.tsx +673 -0
- package/src/components/waka-device-trust/index.tsx +1259 -0
- package/src/components/waka-dock/index.tsx +285 -0
- package/src/components/waka-drawer/index.tsx +319 -0
- package/src/components/waka-empty-state/index.tsx +545 -0
- package/src/components/waka-error-shake/index.tsx +398 -0
- package/src/components/waka-feature-announcement/index.tsx +991 -0
- package/src/components/waka-file-upload/index.tsx +437 -0
- package/src/components/waka-floating-nav/index.tsx +413 -0
- package/src/components/waka-flow-diagram/index.tsx +508 -0
- package/src/components/waka-funnel-chart/index.tsx +823 -0
- package/src/components/waka-glow-card/index.tsx +246 -0
- package/src/components/waka-goal-progress/index.tsx +1025 -0
- package/src/components/waka-haptic-button/index.tsx +388 -0
- package/src/components/waka-health-pulse/index.tsx +451 -0
- package/src/components/waka-heatmap/index.tsx +1026 -0
- package/src/components/waka-hotspot/index.tsx +682 -0
- package/src/components/waka-image/index.tsx +373 -0
- package/src/components/waka-incident-timeline/index.tsx +686 -0
- package/src/components/waka-invoice-preview/index.tsx +829 -0
- package/src/components/waka-kanban/index.tsx +646 -0
- package/src/components/waka-kpi-dashboard/index.tsx +755 -0
- package/src/components/waka-leaderboard/index.tsx +746 -0
- package/src/components/waka-level-progress/index.tsx +665 -0
- package/src/components/waka-liquid-button/index.tsx +520 -0
- package/src/components/waka-loading-orbit/index.tsx +478 -0
- package/src/components/waka-loot-box/index.tsx +1091 -0
- package/src/components/waka-magic-link/index.tsx +321 -0
- package/src/components/waka-magnetic-button/index.tsx +567 -0
- package/src/components/waka-mention-input/index.tsx +953 -0
- package/src/components/waka-metric-sparkline/index.tsx +627 -0
- package/src/components/waka-milestone-road/index.tsx +1064 -0
- package/src/components/waka-modal/index.tsx +374 -0
- package/src/components/waka-morph-button/index.tsx +495 -0
- package/src/components/waka-network-topology/index.tsx +801 -0
- package/src/components/waka-notifications/index.tsx +414 -0
- package/src/components/waka-number-input/index.tsx +373 -0
- package/src/components/waka-orbital-menu/index.tsx +445 -0
- package/src/components/waka-order-tracker/index.tsx +1041 -0
- package/src/components/waka-pagination/index.tsx +393 -0
- package/src/components/waka-password-strength/index.tsx +824 -0
- package/src/components/waka-payment-method-picker/index.tsx +715 -0
- package/src/components/waka-permission-matrix/index.tsx +1302 -0
- package/src/components/waka-phone-input/index.tsx +801 -0
- package/src/components/waka-pipeline-view/index.tsx +604 -0
- package/src/components/waka-player-card/index.tsx +691 -0
- package/src/components/waka-points-popup/index.tsx +366 -0
- package/src/components/waka-power-up/index.tsx +1155 -0
- package/src/components/waka-presence-indicator/index.tsx +1181 -0
- package/src/components/waka-pricing-table/index.tsx +755 -0
- package/src/components/waka-product-card/index.tsx +786 -0
- package/src/components/waka-progress-onboarding/index.tsx +878 -0
- package/src/components/waka-pull-to-refresh/index.tsx +451 -0
- package/src/components/waka-qrcode/index.tsx +232 -0
- package/src/components/waka-quest-card/index.tsx +1275 -0
- package/src/components/waka-quota-bar/index.tsx +693 -0
- package/src/components/waka-radar-score/index.tsx +512 -0
- package/src/components/waka-rank-badge/index.tsx +813 -0
- package/src/components/waka-rating-input/index.tsx +560 -0
- package/src/components/waka-reaction-picker/index.tsx +1062 -0
- package/src/components/waka-region-map/index.tsx +730 -0
- package/src/components/waka-resource-gauge/index.tsx +654 -0
- package/src/components/waka-resource-pool/index.tsx +1035 -0
- package/src/components/waka-rich-text-editor/index.tsx +594 -0
- package/src/components/waka-rollback-slider/index.tsx +891 -0
- package/src/components/waka-sankey-diagram/index.tsx +1032 -0
- package/src/components/waka-schedule-picker/index.tsx +1060 -0
- package/src/components/waka-scratch-card/index.tsx +914 -0
- package/src/components/waka-season-pass/index.tsx +886 -0
- package/src/components/waka-security-score/index.tsx +1126 -0
- package/src/components/waka-segmented-control/index.tsx +238 -0
- package/src/components/waka-server-rack/index.tsx +764 -0
- package/src/components/waka-session-manager/index.tsx +815 -0
- package/src/components/waka-signature-pad/index.tsx +744 -0
- package/src/components/waka-skeleton-wave/index.tsx +454 -0
- package/src/components/waka-skill-tree/index.tsx +1031 -0
- package/src/components/waka-sla-tracker/index.tsx +798 -0
- package/src/components/waka-slider-range/index.tsx +765 -0
- package/src/components/waka-spin-wheel/index.tsx +671 -0
- package/src/components/waka-spinner/index.tsx +284 -0
- package/src/components/waka-spotlight/index.tsx +410 -0
- package/src/components/waka-stat/index.tsx +428 -0
- package/src/components/waka-stats-hexagon/index.tsx +824 -0
- package/src/components/waka-status-matrix/index.tsx +565 -0
- package/src/components/waka-stepper/index.tsx +489 -0
- package/src/components/waka-streak-counter/index.tsx +334 -0
- package/src/components/waka-success-explosion/index.tsx +453 -0
- package/src/components/waka-swipe-card/index.tsx +574 -0
- package/src/components/waka-tabs-morph/index.tsx +509 -0
- package/src/components/waka-tag-input/index.tsx +877 -0
- package/src/components/waka-team-banner/index.tsx +1183 -0
- package/src/components/waka-terminal-output/index.tsx +836 -0
- package/src/components/waka-theme-creator/index.tsx +762 -0
- package/src/components/waka-theme-manager/index.tsx +654 -0
- package/src/components/waka-thread-view/index.tsx +874 -0
- package/src/components/waka-tilt-card/index.tsx +250 -0
- package/src/components/waka-time-picker/index.tsx +479 -0
- package/src/components/waka-timeline/index.tsx +385 -0
- package/src/components/waka-tooltip-tour/index.tsx +855 -0
- package/src/components/waka-tour-guide/index.tsx +920 -0
- package/src/components/waka-tournament-bracket/index.tsx +1276 -0
- package/src/components/waka-tree/index.tsx +557 -0
- package/src/components/waka-treemap-chart/index.tsx +1031 -0
- package/src/components/waka-two-factor-setup/index.tsx +995 -0
- package/src/components/waka-typewriter/index.tsx +566 -0
- package/src/components/waka-typing-indicator/index.tsx +649 -0
- package/src/components/waka-versus-card/index.tsx +1026 -0
- package/src/components/waka-video/index.tsx +557 -0
- package/src/components/waka-video-call/index.tsx +1087 -0
- package/src/components/waka-virtual-list/index.tsx +327 -0
- package/src/components/waka-voice-message/index.tsx +1019 -0
- package/src/components/waka-welcome-modal/index.tsx +790 -0
- package/src/components/waka-xp-bar/index.tsx +799 -0
|
@@ -0,0 +1,1234 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cn } from "../../utils/cn"
|
|
5
|
+
import { AlertTriangle, TrendingDown, TrendingUp, Calendar, DollarSign, Clock, Target } from "lucide-react"
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Types
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
export interface SpendDataPoint {
|
|
12
|
+
/** Date of the spend */
|
|
13
|
+
date: Date
|
|
14
|
+
/** Amount spent on this day */
|
|
15
|
+
amount: number
|
|
16
|
+
/** Category breakdown (optional) */
|
|
17
|
+
categories?: Record<string, number>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BudgetCategory {
|
|
21
|
+
/** Unique identifier */
|
|
22
|
+
id: string
|
|
23
|
+
/** Display name */
|
|
24
|
+
name: string
|
|
25
|
+
/** Allocated budget for this category */
|
|
26
|
+
budget: number
|
|
27
|
+
/** Current spend for this category */
|
|
28
|
+
spent: number
|
|
29
|
+
/** Custom color */
|
|
30
|
+
color?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BudgetBurnState {
|
|
34
|
+
/** Total budget amount */
|
|
35
|
+
totalBudget: number
|
|
36
|
+
/** Total amount spent */
|
|
37
|
+
totalSpent: number
|
|
38
|
+
/** Budget period start date */
|
|
39
|
+
startDate: Date
|
|
40
|
+
/** Budget period end date */
|
|
41
|
+
endDate: Date
|
|
42
|
+
/** Daily spending data */
|
|
43
|
+
spendHistory: SpendDataPoint[]
|
|
44
|
+
/** Budget categories */
|
|
45
|
+
categories: BudgetCategory[]
|
|
46
|
+
/** Current burn rate (spend per day) */
|
|
47
|
+
burnRate: number
|
|
48
|
+
/** Ideal burn rate to stay on budget */
|
|
49
|
+
idealBurnRate: number
|
|
50
|
+
/** Projected depletion date */
|
|
51
|
+
depletionDate: Date | null
|
|
52
|
+
/** Days remaining in period */
|
|
53
|
+
daysRemaining: number
|
|
54
|
+
/** Time elapsed percentage */
|
|
55
|
+
timeElapsedPercent: number
|
|
56
|
+
/** Budget used percentage */
|
|
57
|
+
budgetUsedPercent: number
|
|
58
|
+
/** Danger zone (burn rate is too high) */
|
|
59
|
+
isDangerZone: boolean
|
|
60
|
+
/** Warning zone (burn rate approaching danger) */
|
|
61
|
+
isWarningZone: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface WakaBudgetBurnProps {
|
|
65
|
+
/** Total budget amount */
|
|
66
|
+
budget: number
|
|
67
|
+
/** Budget period start date */
|
|
68
|
+
startDate: Date
|
|
69
|
+
/** Budget period end date */
|
|
70
|
+
endDate: Date
|
|
71
|
+
/** Daily spending history */
|
|
72
|
+
spendHistory: SpendDataPoint[]
|
|
73
|
+
/** Budget categories (optional) */
|
|
74
|
+
categories?: BudgetCategory[]
|
|
75
|
+
/** Currency symbol */
|
|
76
|
+
currency?: string
|
|
77
|
+
/** Danger threshold (percentage over ideal burn rate) */
|
|
78
|
+
dangerThreshold?: number
|
|
79
|
+
/** Warning threshold (percentage over ideal burn rate) */
|
|
80
|
+
warningThreshold?: number
|
|
81
|
+
/** Show projected burn line */
|
|
82
|
+
showProjection?: boolean
|
|
83
|
+
/** Show ideal burn line */
|
|
84
|
+
showIdealLine?: boolean
|
|
85
|
+
/** Show category breakdown */
|
|
86
|
+
showCategories?: boolean
|
|
87
|
+
/** Show daily/weekly/monthly trends */
|
|
88
|
+
showTrends?: boolean
|
|
89
|
+
/** Enable animations */
|
|
90
|
+
animated?: boolean
|
|
91
|
+
/** Chart height in pixels */
|
|
92
|
+
height?: number
|
|
93
|
+
/** Custom value formatter */
|
|
94
|
+
formatValue?: (value: number) => string
|
|
95
|
+
/** Callback when danger zone is entered */
|
|
96
|
+
onDangerZone?: () => void
|
|
97
|
+
/** Callback when budget is depleted */
|
|
98
|
+
onBudgetDepleted?: () => void
|
|
99
|
+
/** Additional CSS classes */
|
|
100
|
+
className?: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// Default Colors
|
|
105
|
+
// ============================================================================
|
|
106
|
+
|
|
107
|
+
const defaultCategoryColors = [
|
|
108
|
+
"#3b82f6", // blue
|
|
109
|
+
"#ef4444", // red
|
|
110
|
+
"#22c55e", // green
|
|
111
|
+
"#f59e0b", // amber
|
|
112
|
+
"#8b5cf6", // violet
|
|
113
|
+
"#ec4899", // pink
|
|
114
|
+
"#14b8a6", // teal
|
|
115
|
+
"#f97316", // orange
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
// ============================================================================
|
|
119
|
+
// Utility Functions
|
|
120
|
+
// ============================================================================
|
|
121
|
+
|
|
122
|
+
function calculateBurnState(
|
|
123
|
+
budget: number,
|
|
124
|
+
startDate: Date,
|
|
125
|
+
endDate: Date,
|
|
126
|
+
spendHistory: SpendDataPoint[],
|
|
127
|
+
categories: BudgetCategory[],
|
|
128
|
+
dangerThreshold: number,
|
|
129
|
+
warningThreshold: number
|
|
130
|
+
): BudgetBurnState {
|
|
131
|
+
const now = new Date()
|
|
132
|
+
const totalDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24))
|
|
133
|
+
const daysElapsed = Math.max(0, Math.ceil((now.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)))
|
|
134
|
+
const daysRemaining = Math.max(0, totalDays - daysElapsed)
|
|
135
|
+
|
|
136
|
+
const totalSpent = spendHistory.reduce((sum, point) => sum + point.amount, 0)
|
|
137
|
+
const budgetRemaining = Math.max(0, budget - totalSpent)
|
|
138
|
+
|
|
139
|
+
const timeElapsedPercent = Math.min(100, (daysElapsed / totalDays) * 100)
|
|
140
|
+
const budgetUsedPercent = Math.min(100, (totalSpent / budget) * 100)
|
|
141
|
+
|
|
142
|
+
// Calculate burn rates
|
|
143
|
+
const burnRate = daysElapsed > 0 ? totalSpent / daysElapsed : 0
|
|
144
|
+
const idealBurnRate = budget / totalDays
|
|
145
|
+
|
|
146
|
+
// Project depletion date
|
|
147
|
+
let depletionDate: Date | null = null
|
|
148
|
+
if (burnRate > 0 && budgetRemaining > 0) {
|
|
149
|
+
const daysUntilDepletion = budgetRemaining / burnRate
|
|
150
|
+
depletionDate = new Date(now.getTime() + daysUntilDepletion * 24 * 60 * 60 * 1000)
|
|
151
|
+
} else if (totalSpent >= budget) {
|
|
152
|
+
depletionDate = now
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Determine zones
|
|
156
|
+
const burnRateRatio = idealBurnRate > 0 ? burnRate / idealBurnRate : 0
|
|
157
|
+
const isDangerZone = burnRateRatio >= 1 + dangerThreshold / 100
|
|
158
|
+
const isWarningZone = !isDangerZone && burnRateRatio >= 1 + warningThreshold / 100
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
totalBudget: budget,
|
|
162
|
+
totalSpent,
|
|
163
|
+
startDate,
|
|
164
|
+
endDate,
|
|
165
|
+
spendHistory,
|
|
166
|
+
categories,
|
|
167
|
+
burnRate,
|
|
168
|
+
idealBurnRate,
|
|
169
|
+
depletionDate,
|
|
170
|
+
daysRemaining,
|
|
171
|
+
timeElapsedPercent,
|
|
172
|
+
budgetUsedPercent,
|
|
173
|
+
isDangerZone,
|
|
174
|
+
isWarningZone,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function formatDate(date: Date): string {
|
|
179
|
+
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function calculateCumulativeSpend(spendHistory: SpendDataPoint[]): Array<{ date: Date; cumulative: number }> {
|
|
183
|
+
let cumulative = 0
|
|
184
|
+
return spendHistory
|
|
185
|
+
.sort((a, b) => a.date.getTime() - b.date.getTime())
|
|
186
|
+
.map((point) => {
|
|
187
|
+
cumulative += point.amount
|
|
188
|
+
return { date: point.date, cumulative }
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function calculateTrend(values: number[]): { direction: "up" | "down" | "neutral"; percentage: number } {
|
|
193
|
+
if (values.length < 2) return { direction: "neutral", percentage: 0 }
|
|
194
|
+
|
|
195
|
+
const recentAvg = values.slice(-3).reduce((a, b) => a + b, 0) / Math.min(3, values.length)
|
|
196
|
+
const olderAvg = values.slice(0, -3).reduce((a, b) => a + b, 0) / Math.max(1, values.length - 3)
|
|
197
|
+
|
|
198
|
+
if (olderAvg === 0) return { direction: "neutral", percentage: 0 }
|
|
199
|
+
|
|
200
|
+
const change = ((recentAvg - olderAvg) / olderAvg) * 100
|
|
201
|
+
|
|
202
|
+
if (Math.abs(change) < 5) return { direction: "neutral", percentage: 0 }
|
|
203
|
+
return {
|
|
204
|
+
direction: change > 0 ? "up" : "down",
|
|
205
|
+
percentage: Math.abs(change),
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ============================================================================
|
|
210
|
+
// Burn Chart Component
|
|
211
|
+
// ============================================================================
|
|
212
|
+
|
|
213
|
+
interface BurnChartProps {
|
|
214
|
+
state: BudgetBurnState
|
|
215
|
+
width: number
|
|
216
|
+
height: number
|
|
217
|
+
showProjection: boolean
|
|
218
|
+
showIdealLine: boolean
|
|
219
|
+
animated: boolean
|
|
220
|
+
formatValue: (value: number) => string
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function BurnChart({
|
|
224
|
+
state,
|
|
225
|
+
width,
|
|
226
|
+
height,
|
|
227
|
+
showProjection,
|
|
228
|
+
showIdealLine,
|
|
229
|
+
animated,
|
|
230
|
+
formatValue,
|
|
231
|
+
}: BurnChartProps) {
|
|
232
|
+
const [animationProgress, setAnimationProgress] = React.useState(animated ? 0 : 1)
|
|
233
|
+
const [hoveredPoint, setHoveredPoint] = React.useState<{
|
|
234
|
+
index: number
|
|
235
|
+
x: number
|
|
236
|
+
y: number
|
|
237
|
+
value: number
|
|
238
|
+
date: Date
|
|
239
|
+
} | null>(null)
|
|
240
|
+
|
|
241
|
+
const padding = { top: 20, right: 20, bottom: 30, left: 60 }
|
|
242
|
+
const chartWidth = width - padding.left - padding.right
|
|
243
|
+
const chartHeight = height - padding.top - padding.bottom
|
|
244
|
+
|
|
245
|
+
// Calculate cumulative spending
|
|
246
|
+
const cumulativeData = React.useMemo(
|
|
247
|
+
() => calculateCumulativeSpend(state.spendHistory),
|
|
248
|
+
[state.spendHistory]
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
// Calculate total days for x-axis
|
|
252
|
+
const totalDays = Math.ceil(
|
|
253
|
+
(state.endDate.getTime() - state.startDate.getTime()) / (1000 * 60 * 60 * 24)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
// Scale functions
|
|
257
|
+
const xScale = (date: Date) => {
|
|
258
|
+
const dayIndex = Math.ceil(
|
|
259
|
+
(date.getTime() - state.startDate.getTime()) / (1000 * 60 * 60 * 24)
|
|
260
|
+
)
|
|
261
|
+
return padding.left + (dayIndex / totalDays) * chartWidth
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const yScale = (value: number) => {
|
|
265
|
+
return padding.top + chartHeight - (value / state.totalBudget) * chartHeight
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Generate path for actual burn line
|
|
269
|
+
const burnPath = React.useMemo(() => {
|
|
270
|
+
if (cumulativeData.length === 0) return ""
|
|
271
|
+
|
|
272
|
+
const points = cumulativeData.map((d) => ({
|
|
273
|
+
x: xScale(d.date),
|
|
274
|
+
y: yScale(d.cumulative),
|
|
275
|
+
}))
|
|
276
|
+
|
|
277
|
+
return points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ")
|
|
278
|
+
}, [cumulativeData, chartWidth, chartHeight])
|
|
279
|
+
|
|
280
|
+
// Generate ideal burn line
|
|
281
|
+
const idealPath = React.useMemo(() => {
|
|
282
|
+
const startX = padding.left
|
|
283
|
+
const startY = yScale(0)
|
|
284
|
+
const endX = padding.left + chartWidth
|
|
285
|
+
const endY = yScale(state.totalBudget)
|
|
286
|
+
return `M ${startX} ${startY} L ${endX} ${endY}`
|
|
287
|
+
}, [chartWidth, chartHeight, state.totalBudget])
|
|
288
|
+
|
|
289
|
+
// Generate projection line
|
|
290
|
+
const projectionPath = React.useMemo(() => {
|
|
291
|
+
if (cumulativeData.length === 0 || state.burnRate === 0) return ""
|
|
292
|
+
|
|
293
|
+
const lastPoint = cumulativeData[cumulativeData.length - 1]
|
|
294
|
+
const startX = xScale(lastPoint.date)
|
|
295
|
+
const startY = yScale(lastPoint.cumulative)
|
|
296
|
+
|
|
297
|
+
// Project to budget depletion or end date
|
|
298
|
+
const endDate = state.depletionDate && state.depletionDate < state.endDate
|
|
299
|
+
? state.depletionDate
|
|
300
|
+
: state.endDate
|
|
301
|
+
|
|
302
|
+
const daysToProject = Math.ceil(
|
|
303
|
+
(endDate.getTime() - lastPoint.date.getTime()) / (1000 * 60 * 60 * 24)
|
|
304
|
+
)
|
|
305
|
+
const projectedSpend = lastPoint.cumulative + state.burnRate * daysToProject
|
|
306
|
+
|
|
307
|
+
const endX = xScale(endDate)
|
|
308
|
+
const endY = yScale(Math.min(projectedSpend, state.totalBudget))
|
|
309
|
+
|
|
310
|
+
return `M ${startX} ${startY} L ${endX} ${endY}`
|
|
311
|
+
}, [cumulativeData, state.burnRate, state.depletionDate, state.endDate, chartWidth, chartHeight])
|
|
312
|
+
|
|
313
|
+
// Danger zone area (above ideal burn)
|
|
314
|
+
const dangerZonePath = React.useMemo(() => {
|
|
315
|
+
const topLeft = `${padding.left},${padding.top}`
|
|
316
|
+
const topRight = `${padding.left + chartWidth},${padding.top}`
|
|
317
|
+
const bottomRight = `${padding.left + chartWidth},${yScale(state.totalBudget)}`
|
|
318
|
+
const bottomLeft = `${padding.left},${yScale(0)}`
|
|
319
|
+
return `M ${topLeft} L ${topRight} L ${bottomRight} L ${bottomLeft} Z`
|
|
320
|
+
}, [chartWidth, chartHeight, state.totalBudget])
|
|
321
|
+
|
|
322
|
+
// Animation effect
|
|
323
|
+
React.useEffect(() => {
|
|
324
|
+
if (!animated) {
|
|
325
|
+
setAnimationProgress(1)
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const duration = 1500
|
|
330
|
+
const startTime = Date.now()
|
|
331
|
+
|
|
332
|
+
const animate = () => {
|
|
333
|
+
const elapsed = Date.now() - startTime
|
|
334
|
+
const progress = Math.min(elapsed / duration, 1)
|
|
335
|
+
const eased = 1 - Math.pow(1 - progress, 3)
|
|
336
|
+
setAnimationProgress(eased)
|
|
337
|
+
|
|
338
|
+
if (progress < 1) {
|
|
339
|
+
requestAnimationFrame(animate)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
requestAnimationFrame(animate)
|
|
344
|
+
}, [animated])
|
|
345
|
+
|
|
346
|
+
// Generate tick marks
|
|
347
|
+
const xTicks = React.useMemo(() => {
|
|
348
|
+
const ticks: Array<{ date: Date; x: number }> = []
|
|
349
|
+
const tickCount = Math.min(6, totalDays)
|
|
350
|
+
|
|
351
|
+
for (let i = 0; i <= tickCount; i++) {
|
|
352
|
+
const date = new Date(
|
|
353
|
+
state.startDate.getTime() + (i / tickCount) * (state.endDate.getTime() - state.startDate.getTime())
|
|
354
|
+
)
|
|
355
|
+
ticks.push({ date, x: xScale(date) })
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return ticks
|
|
359
|
+
}, [state.startDate, state.endDate, totalDays, chartWidth])
|
|
360
|
+
|
|
361
|
+
const yTicks = React.useMemo(() => {
|
|
362
|
+
const ticks: Array<{ value: number; y: number }> = []
|
|
363
|
+
const tickCount = 4
|
|
364
|
+
|
|
365
|
+
for (let i = 0; i <= tickCount; i++) {
|
|
366
|
+
const value = (i / tickCount) * state.totalBudget
|
|
367
|
+
ticks.push({ value, y: yScale(value) })
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return ticks
|
|
371
|
+
}, [state.totalBudget, chartHeight])
|
|
372
|
+
|
|
373
|
+
// Handle hover on burn line points
|
|
374
|
+
const handlePointHover = (index: number) => {
|
|
375
|
+
const point = cumulativeData[index]
|
|
376
|
+
if (point) {
|
|
377
|
+
setHoveredPoint({
|
|
378
|
+
index,
|
|
379
|
+
x: xScale(point.date),
|
|
380
|
+
y: yScale(point.cumulative),
|
|
381
|
+
value: point.cumulative,
|
|
382
|
+
date: point.date,
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
<div className="relative">
|
|
389
|
+
<svg width={width} height={height} className="overflow-visible">
|
|
390
|
+
<defs>
|
|
391
|
+
{/* Gradient for danger zone */}
|
|
392
|
+
<linearGradient id="dangerZoneGradient" x1="0" y1="0" x2="0" y2="1">
|
|
393
|
+
<stop offset="0%" stopColor="#ef4444" stopOpacity="0.15" />
|
|
394
|
+
<stop offset="100%" stopColor="#ef4444" stopOpacity="0.02" />
|
|
395
|
+
</linearGradient>
|
|
396
|
+
|
|
397
|
+
{/* Gradient for burn line */}
|
|
398
|
+
<linearGradient id="burnLineGradient" x1="0" y1="0" x2="1" y2="0">
|
|
399
|
+
<stop offset="0%" stopColor="#3b82f6" />
|
|
400
|
+
<stop offset="100%" stopColor={state.isDangerZone ? "#ef4444" : state.isWarningZone ? "#f59e0b" : "#22c55e"} />
|
|
401
|
+
</linearGradient>
|
|
402
|
+
|
|
403
|
+
{/* Clip path for animation */}
|
|
404
|
+
<clipPath id="burnLineClip">
|
|
405
|
+
<rect
|
|
406
|
+
x={padding.left}
|
|
407
|
+
y={padding.top}
|
|
408
|
+
width={chartWidth * animationProgress}
|
|
409
|
+
height={chartHeight}
|
|
410
|
+
/>
|
|
411
|
+
</clipPath>
|
|
412
|
+
</defs>
|
|
413
|
+
|
|
414
|
+
{/* Background grid */}
|
|
415
|
+
<g className="text-muted-foreground/20">
|
|
416
|
+
{yTicks.map((tick, i) => (
|
|
417
|
+
<line
|
|
418
|
+
key={i}
|
|
419
|
+
x1={padding.left}
|
|
420
|
+
y1={tick.y}
|
|
421
|
+
x2={padding.left + chartWidth}
|
|
422
|
+
y2={tick.y}
|
|
423
|
+
stroke="currentColor"
|
|
424
|
+
strokeDasharray="4,4"
|
|
425
|
+
/>
|
|
426
|
+
))}
|
|
427
|
+
</g>
|
|
428
|
+
|
|
429
|
+
{/* Danger zone fill */}
|
|
430
|
+
<path
|
|
431
|
+
d={`M ${padding.left} ${padding.top}
|
|
432
|
+
L ${padding.left + chartWidth} ${yScale(state.totalBudget)}
|
|
433
|
+
L ${padding.left + chartWidth} ${padding.top} Z`}
|
|
434
|
+
fill="url(#dangerZoneGradient)"
|
|
435
|
+
/>
|
|
436
|
+
|
|
437
|
+
{/* Y-axis */}
|
|
438
|
+
<g className="text-muted-foreground text-xs">
|
|
439
|
+
{yTicks.map((tick, i) => (
|
|
440
|
+
<g key={i}>
|
|
441
|
+
<text
|
|
442
|
+
x={padding.left - 8}
|
|
443
|
+
y={tick.y}
|
|
444
|
+
textAnchor="end"
|
|
445
|
+
dominantBaseline="middle"
|
|
446
|
+
fill="currentColor"
|
|
447
|
+
>
|
|
448
|
+
{formatValue(tick.value)}
|
|
449
|
+
</text>
|
|
450
|
+
</g>
|
|
451
|
+
))}
|
|
452
|
+
</g>
|
|
453
|
+
|
|
454
|
+
{/* X-axis */}
|
|
455
|
+
<g className="text-muted-foreground text-xs">
|
|
456
|
+
{xTicks.map((tick, i) => (
|
|
457
|
+
<text
|
|
458
|
+
key={i}
|
|
459
|
+
x={tick.x}
|
|
460
|
+
y={height - 8}
|
|
461
|
+
textAnchor="middle"
|
|
462
|
+
fill="currentColor"
|
|
463
|
+
>
|
|
464
|
+
{formatDate(tick.date)}
|
|
465
|
+
</text>
|
|
466
|
+
))}
|
|
467
|
+
</g>
|
|
468
|
+
|
|
469
|
+
{/* Ideal burn line */}
|
|
470
|
+
{showIdealLine && (
|
|
471
|
+
<path
|
|
472
|
+
d={idealPath}
|
|
473
|
+
fill="none"
|
|
474
|
+
stroke="#94a3b8"
|
|
475
|
+
strokeWidth="2"
|
|
476
|
+
strokeDasharray="8,4"
|
|
477
|
+
opacity="0.6"
|
|
478
|
+
/>
|
|
479
|
+
)}
|
|
480
|
+
|
|
481
|
+
{/* Actual burn line with animation */}
|
|
482
|
+
<g clipPath="url(#burnLineClip)">
|
|
483
|
+
<path
|
|
484
|
+
d={burnPath}
|
|
485
|
+
fill="none"
|
|
486
|
+
stroke="url(#burnLineGradient)"
|
|
487
|
+
strokeWidth="3"
|
|
488
|
+
strokeLinecap="round"
|
|
489
|
+
strokeLinejoin="round"
|
|
490
|
+
/>
|
|
491
|
+
</g>
|
|
492
|
+
|
|
493
|
+
{/* Projection line */}
|
|
494
|
+
{showProjection && projectionPath && animationProgress === 1 && (
|
|
495
|
+
<path
|
|
496
|
+
d={projectionPath}
|
|
497
|
+
fill="none"
|
|
498
|
+
stroke={state.isDangerZone ? "#ef4444" : state.isWarningZone ? "#f59e0b" : "#22c55e"}
|
|
499
|
+
strokeWidth="2"
|
|
500
|
+
strokeDasharray="6,3"
|
|
501
|
+
opacity="0.6"
|
|
502
|
+
className="animate-burn-dash"
|
|
503
|
+
/>
|
|
504
|
+
)}
|
|
505
|
+
|
|
506
|
+
{/* Current position marker */}
|
|
507
|
+
{cumulativeData.length > 0 && animationProgress === 1 && (
|
|
508
|
+
<g>
|
|
509
|
+
<circle
|
|
510
|
+
cx={xScale(cumulativeData[cumulativeData.length - 1].date)}
|
|
511
|
+
cy={yScale(cumulativeData[cumulativeData.length - 1].cumulative)}
|
|
512
|
+
r="6"
|
|
513
|
+
fill={state.isDangerZone ? "#ef4444" : state.isWarningZone ? "#f59e0b" : "#3b82f6"}
|
|
514
|
+
stroke="white"
|
|
515
|
+
strokeWidth="2"
|
|
516
|
+
className="animate-burn-pulse"
|
|
517
|
+
/>
|
|
518
|
+
</g>
|
|
519
|
+
)}
|
|
520
|
+
|
|
521
|
+
{/* Budget line */}
|
|
522
|
+
<line
|
|
523
|
+
x1={padding.left}
|
|
524
|
+
y1={yScale(state.totalBudget)}
|
|
525
|
+
x2={padding.left + chartWidth}
|
|
526
|
+
y2={yScale(state.totalBudget)}
|
|
527
|
+
stroke="#ef4444"
|
|
528
|
+
strokeWidth="2"
|
|
529
|
+
strokeDasharray="4,2"
|
|
530
|
+
/>
|
|
531
|
+
|
|
532
|
+
{/* Today marker */}
|
|
533
|
+
{state.timeElapsedPercent < 100 && (
|
|
534
|
+
<g>
|
|
535
|
+
<line
|
|
536
|
+
x1={padding.left + (state.timeElapsedPercent / 100) * chartWidth}
|
|
537
|
+
y1={padding.top}
|
|
538
|
+
x2={padding.left + (state.timeElapsedPercent / 100) * chartWidth}
|
|
539
|
+
y2={padding.top + chartHeight}
|
|
540
|
+
stroke="#6366f1"
|
|
541
|
+
strokeWidth="1"
|
|
542
|
+
strokeDasharray="4,4"
|
|
543
|
+
opacity="0.5"
|
|
544
|
+
/>
|
|
545
|
+
<text
|
|
546
|
+
x={padding.left + (state.timeElapsedPercent / 100) * chartWidth}
|
|
547
|
+
y={padding.top - 6}
|
|
548
|
+
textAnchor="middle"
|
|
549
|
+
className="text-xs fill-indigo-500 font-medium"
|
|
550
|
+
>
|
|
551
|
+
Today
|
|
552
|
+
</text>
|
|
553
|
+
</g>
|
|
554
|
+
)}
|
|
555
|
+
|
|
556
|
+
{/* Hover points */}
|
|
557
|
+
{animationProgress === 1 && cumulativeData.map((point, i) => (
|
|
558
|
+
<circle
|
|
559
|
+
key={i}
|
|
560
|
+
cx={xScale(point.date)}
|
|
561
|
+
cy={yScale(point.cumulative)}
|
|
562
|
+
r="8"
|
|
563
|
+
fill="transparent"
|
|
564
|
+
className="cursor-pointer"
|
|
565
|
+
onMouseEnter={() => handlePointHover(i)}
|
|
566
|
+
onMouseLeave={() => setHoveredPoint(null)}
|
|
567
|
+
/>
|
|
568
|
+
))}
|
|
569
|
+
|
|
570
|
+
{/* Depletion date marker */}
|
|
571
|
+
{showProjection && state.depletionDate && state.depletionDate <= state.endDate && animationProgress === 1 && (
|
|
572
|
+
<g>
|
|
573
|
+
<line
|
|
574
|
+
x1={xScale(state.depletionDate)}
|
|
575
|
+
y1={padding.top}
|
|
576
|
+
x2={xScale(state.depletionDate)}
|
|
577
|
+
y2={padding.top + chartHeight}
|
|
578
|
+
stroke="#ef4444"
|
|
579
|
+
strokeWidth="2"
|
|
580
|
+
strokeDasharray="4,4"
|
|
581
|
+
/>
|
|
582
|
+
<g transform={`translate(${xScale(state.depletionDate)}, ${padding.top + 10})`}>
|
|
583
|
+
<rect
|
|
584
|
+
x="-40"
|
|
585
|
+
y="-8"
|
|
586
|
+
width="80"
|
|
587
|
+
height="16"
|
|
588
|
+
rx="4"
|
|
589
|
+
fill="#ef4444"
|
|
590
|
+
/>
|
|
591
|
+
<text
|
|
592
|
+
textAnchor="middle"
|
|
593
|
+
dominantBaseline="middle"
|
|
594
|
+
className="text-[10px] font-medium fill-white"
|
|
595
|
+
>
|
|
596
|
+
Depleted
|
|
597
|
+
</text>
|
|
598
|
+
</g>
|
|
599
|
+
</g>
|
|
600
|
+
)}
|
|
601
|
+
</svg>
|
|
602
|
+
|
|
603
|
+
{/* Tooltip */}
|
|
604
|
+
{hoveredPoint && (
|
|
605
|
+
<div
|
|
606
|
+
className={cn(
|
|
607
|
+
"absolute z-50 px-3 py-2 text-sm rounded-lg shadow-lg",
|
|
608
|
+
"bg-foreground text-background",
|
|
609
|
+
"animate-in fade-in-0 zoom-in-95 duration-100",
|
|
610
|
+
"pointer-events-none"
|
|
611
|
+
)}
|
|
612
|
+
style={{
|
|
613
|
+
left: hoveredPoint.x,
|
|
614
|
+
top: hoveredPoint.y - 60,
|
|
615
|
+
transform: "translateX(-50%)",
|
|
616
|
+
}}
|
|
617
|
+
>
|
|
618
|
+
<div className="font-semibold">{formatDate(hoveredPoint.date)}</div>
|
|
619
|
+
<div className="text-xs opacity-80">
|
|
620
|
+
Cumulative: {formatValue(hoveredPoint.value)}
|
|
621
|
+
</div>
|
|
622
|
+
<div
|
|
623
|
+
className="absolute left-1/2 -translate-x-1/2 -bottom-1.5 w-3 h-3 rotate-45 bg-foreground"
|
|
624
|
+
/>
|
|
625
|
+
</div>
|
|
626
|
+
)}
|
|
627
|
+
|
|
628
|
+
{/* CSS Animations */}
|
|
629
|
+
<style>{`
|
|
630
|
+
@keyframes burn-pulse {
|
|
631
|
+
0%, 100% { transform: scale(1); opacity: 1; }
|
|
632
|
+
50% { transform: scale(1.2); opacity: 0.8; }
|
|
633
|
+
}
|
|
634
|
+
@keyframes burn-dash {
|
|
635
|
+
0% { stroke-dashoffset: 0; }
|
|
636
|
+
100% { stroke-dashoffset: 18; }
|
|
637
|
+
}
|
|
638
|
+
.animate-burn-pulse {
|
|
639
|
+
animation: burn-pulse 2s ease-in-out infinite;
|
|
640
|
+
transform-origin: center;
|
|
641
|
+
transform-box: fill-box;
|
|
642
|
+
}
|
|
643
|
+
.animate-burn-dash {
|
|
644
|
+
animation: burn-dash 1s linear infinite;
|
|
645
|
+
}
|
|
646
|
+
`}</style>
|
|
647
|
+
</div>
|
|
648
|
+
)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ============================================================================
|
|
652
|
+
// Stats Panel Component
|
|
653
|
+
// ============================================================================
|
|
654
|
+
|
|
655
|
+
interface StatsPanelProps {
|
|
656
|
+
state: BudgetBurnState
|
|
657
|
+
formatValue: (value: number) => string
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function StatsPanel({ state, formatValue }: StatsPanelProps) {
|
|
661
|
+
const budgetRemaining = state.totalBudget - state.totalSpent
|
|
662
|
+
const burnRateVsIdeal = state.idealBurnRate > 0
|
|
663
|
+
? ((state.burnRate - state.idealBurnRate) / state.idealBurnRate) * 100
|
|
664
|
+
: 0
|
|
665
|
+
|
|
666
|
+
return (
|
|
667
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
668
|
+
{/* Budget Remaining */}
|
|
669
|
+
<div className="rounded-lg border bg-card p-4">
|
|
670
|
+
<div className="flex items-center gap-2 text-muted-foreground text-sm mb-1">
|
|
671
|
+
<DollarSign className="h-4 w-4" />
|
|
672
|
+
<span>Remaining</span>
|
|
673
|
+
</div>
|
|
674
|
+
<div className={cn(
|
|
675
|
+
"text-2xl font-bold",
|
|
676
|
+
budgetRemaining < 0 && "text-red-600"
|
|
677
|
+
)}>
|
|
678
|
+
{formatValue(Math.max(0, budgetRemaining))}
|
|
679
|
+
</div>
|
|
680
|
+
<div className="text-xs text-muted-foreground mt-1">
|
|
681
|
+
{(100 - state.budgetUsedPercent).toFixed(1)}% of budget
|
|
682
|
+
</div>
|
|
683
|
+
</div>
|
|
684
|
+
|
|
685
|
+
{/* Burn Rate */}
|
|
686
|
+
<div className="rounded-lg border bg-card p-4">
|
|
687
|
+
<div className="flex items-center gap-2 text-muted-foreground text-sm mb-1">
|
|
688
|
+
<TrendingDown className="h-4 w-4" />
|
|
689
|
+
<span>Burn Rate</span>
|
|
690
|
+
</div>
|
|
691
|
+
<div className={cn(
|
|
692
|
+
"text-2xl font-bold",
|
|
693
|
+
state.isDangerZone && "text-red-600",
|
|
694
|
+
state.isWarningZone && "text-amber-600"
|
|
695
|
+
)}>
|
|
696
|
+
{formatValue(state.burnRate)}/day
|
|
697
|
+
</div>
|
|
698
|
+
<div className={cn(
|
|
699
|
+
"text-xs mt-1 flex items-center gap-1",
|
|
700
|
+
burnRateVsIdeal > 0 ? "text-red-600" : "text-green-600"
|
|
701
|
+
)}>
|
|
702
|
+
{burnRateVsIdeal > 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
|
703
|
+
{Math.abs(burnRateVsIdeal).toFixed(1)}% vs ideal
|
|
704
|
+
</div>
|
|
705
|
+
</div>
|
|
706
|
+
|
|
707
|
+
{/* Days Remaining */}
|
|
708
|
+
<div className="rounded-lg border bg-card p-4">
|
|
709
|
+
<div className="flex items-center gap-2 text-muted-foreground text-sm mb-1">
|
|
710
|
+
<Clock className="h-4 w-4" />
|
|
711
|
+
<span>Days Left</span>
|
|
712
|
+
</div>
|
|
713
|
+
<div className="text-2xl font-bold">
|
|
714
|
+
{state.daysRemaining}
|
|
715
|
+
</div>
|
|
716
|
+
<div className="text-xs text-muted-foreground mt-1">
|
|
717
|
+
{state.timeElapsedPercent.toFixed(1)}% elapsed
|
|
718
|
+
</div>
|
|
719
|
+
</div>
|
|
720
|
+
|
|
721
|
+
{/* Depletion Forecast */}
|
|
722
|
+
<div className="rounded-lg border bg-card p-4">
|
|
723
|
+
<div className="flex items-center gap-2 text-muted-foreground text-sm mb-1">
|
|
724
|
+
<Calendar className="h-4 w-4" />
|
|
725
|
+
<span>Depletion Date</span>
|
|
726
|
+
</div>
|
|
727
|
+
<div className={cn(
|
|
728
|
+
"text-lg font-bold",
|
|
729
|
+
state.depletionDate && state.depletionDate < state.endDate && "text-red-600"
|
|
730
|
+
)}>
|
|
731
|
+
{state.depletionDate
|
|
732
|
+
? formatDate(state.depletionDate)
|
|
733
|
+
: "On Track"}
|
|
734
|
+
</div>
|
|
735
|
+
{state.depletionDate && state.depletionDate < state.endDate && (
|
|
736
|
+
<div className="text-xs text-red-600 mt-1 flex items-center gap-1">
|
|
737
|
+
<AlertTriangle className="h-3 w-3" />
|
|
738
|
+
Before period ends
|
|
739
|
+
</div>
|
|
740
|
+
)}
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ============================================================================
|
|
747
|
+
// Trend Indicators Component
|
|
748
|
+
// ============================================================================
|
|
749
|
+
|
|
750
|
+
interface TrendIndicatorsProps {
|
|
751
|
+
state: BudgetBurnState
|
|
752
|
+
formatValue: (value: number) => string
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function TrendIndicators({ state, formatValue }: TrendIndicatorsProps) {
|
|
756
|
+
const dailyAmounts = state.spendHistory.map((p) => p.amount)
|
|
757
|
+
const dailyTrend = calculateTrend(dailyAmounts)
|
|
758
|
+
|
|
759
|
+
// Calculate weekly averages
|
|
760
|
+
const weeklyAverages: number[] = []
|
|
761
|
+
for (let i = 0; i < dailyAmounts.length; i += 7) {
|
|
762
|
+
const weekData = dailyAmounts.slice(i, i + 7)
|
|
763
|
+
if (weekData.length > 0) {
|
|
764
|
+
weeklyAverages.push(weekData.reduce((a, b) => a + b, 0) / weekData.length)
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const weeklyTrend = calculateTrend(weeklyAverages)
|
|
768
|
+
|
|
769
|
+
const avgDaily = dailyAmounts.length > 0
|
|
770
|
+
? dailyAmounts.reduce((a, b) => a + b, 0) / dailyAmounts.length
|
|
771
|
+
: 0
|
|
772
|
+
const avgWeekly = avgDaily * 7
|
|
773
|
+
|
|
774
|
+
return (
|
|
775
|
+
<div className="flex flex-wrap gap-4">
|
|
776
|
+
{/* Daily Trend */}
|
|
777
|
+
<div className="flex items-center gap-3 px-4 py-2 rounded-lg bg-muted/50">
|
|
778
|
+
<div>
|
|
779
|
+
<div className="text-xs text-muted-foreground">Daily Avg</div>
|
|
780
|
+
<div className="font-semibold">{formatValue(avgDaily)}</div>
|
|
781
|
+
</div>
|
|
782
|
+
{dailyTrend.direction !== "neutral" && (
|
|
783
|
+
<div className={cn(
|
|
784
|
+
"flex items-center gap-1 text-sm font-medium",
|
|
785
|
+
dailyTrend.direction === "up" ? "text-red-600" : "text-green-600"
|
|
786
|
+
)}>
|
|
787
|
+
{dailyTrend.direction === "up" ? (
|
|
788
|
+
<TrendingUp className="h-4 w-4" />
|
|
789
|
+
) : (
|
|
790
|
+
<TrendingDown className="h-4 w-4" />
|
|
791
|
+
)}
|
|
792
|
+
{dailyTrend.percentage.toFixed(1)}%
|
|
793
|
+
</div>
|
|
794
|
+
)}
|
|
795
|
+
</div>
|
|
796
|
+
|
|
797
|
+
{/* Weekly Trend */}
|
|
798
|
+
<div className="flex items-center gap-3 px-4 py-2 rounded-lg bg-muted/50">
|
|
799
|
+
<div>
|
|
800
|
+
<div className="text-xs text-muted-foreground">Weekly Avg</div>
|
|
801
|
+
<div className="font-semibold">{formatValue(avgWeekly)}</div>
|
|
802
|
+
</div>
|
|
803
|
+
{weeklyTrend.direction !== "neutral" && (
|
|
804
|
+
<div className={cn(
|
|
805
|
+
"flex items-center gap-1 text-sm font-medium",
|
|
806
|
+
weeklyTrend.direction === "up" ? "text-red-600" : "text-green-600"
|
|
807
|
+
)}>
|
|
808
|
+
{weeklyTrend.direction === "up" ? (
|
|
809
|
+
<TrendingUp className="h-4 w-4" />
|
|
810
|
+
) : (
|
|
811
|
+
<TrendingDown className="h-4 w-4" />
|
|
812
|
+
)}
|
|
813
|
+
{weeklyTrend.percentage.toFixed(1)}%
|
|
814
|
+
</div>
|
|
815
|
+
)}
|
|
816
|
+
</div>
|
|
817
|
+
|
|
818
|
+
{/* Monthly projection */}
|
|
819
|
+
<div className="flex items-center gap-3 px-4 py-2 rounded-lg bg-muted/50">
|
|
820
|
+
<div>
|
|
821
|
+
<div className="text-xs text-muted-foreground">Monthly Proj.</div>
|
|
822
|
+
<div className="font-semibold">{formatValue(state.burnRate * 30)}</div>
|
|
823
|
+
</div>
|
|
824
|
+
<div className={cn(
|
|
825
|
+
"text-xs",
|
|
826
|
+
state.burnRate * 30 > state.totalBudget ? "text-red-600" : "text-green-600"
|
|
827
|
+
)}>
|
|
828
|
+
{state.burnRate * 30 > state.totalBudget ? "Over budget" : "On track"}
|
|
829
|
+
</div>
|
|
830
|
+
</div>
|
|
831
|
+
</div>
|
|
832
|
+
)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ============================================================================
|
|
836
|
+
// Category Breakdown Component
|
|
837
|
+
// ============================================================================
|
|
838
|
+
|
|
839
|
+
interface CategoryBreakdownProps {
|
|
840
|
+
categories: BudgetCategory[]
|
|
841
|
+
formatValue: (value: number) => string
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function CategoryBreakdown({ categories, formatValue }: CategoryBreakdownProps) {
|
|
845
|
+
return (
|
|
846
|
+
<div className="space-y-3">
|
|
847
|
+
<div className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
848
|
+
<Target className="h-4 w-4" />
|
|
849
|
+
Category Breakdown
|
|
850
|
+
</div>
|
|
851
|
+
<div className="space-y-2">
|
|
852
|
+
{categories.map((category, index) => {
|
|
853
|
+
const percentage = (category.spent / category.budget) * 100
|
|
854
|
+
const isOverBudget = category.spent > category.budget
|
|
855
|
+
const color = category.color || defaultCategoryColors[index % defaultCategoryColors.length]
|
|
856
|
+
|
|
857
|
+
return (
|
|
858
|
+
<div key={category.id} className="space-y-1">
|
|
859
|
+
<div className="flex items-center justify-between text-sm">
|
|
860
|
+
<div className="flex items-center gap-2">
|
|
861
|
+
<span
|
|
862
|
+
className="w-3 h-3 rounded-full"
|
|
863
|
+
style={{ backgroundColor: color }}
|
|
864
|
+
/>
|
|
865
|
+
<span className="font-medium">{category.name}</span>
|
|
866
|
+
</div>
|
|
867
|
+
<div className="flex items-center gap-2">
|
|
868
|
+
<span className={cn(isOverBudget && "text-red-600")}>
|
|
869
|
+
{formatValue(category.spent)}
|
|
870
|
+
</span>
|
|
871
|
+
<span className="text-muted-foreground">
|
|
872
|
+
/ {formatValue(category.budget)}
|
|
873
|
+
</span>
|
|
874
|
+
</div>
|
|
875
|
+
</div>
|
|
876
|
+
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
|
877
|
+
<div
|
|
878
|
+
className={cn(
|
|
879
|
+
"h-full rounded-full transition-all duration-500",
|
|
880
|
+
isOverBudget && "animate-pulse"
|
|
881
|
+
)}
|
|
882
|
+
style={{
|
|
883
|
+
width: `${Math.min(percentage, 100)}%`,
|
|
884
|
+
backgroundColor: isOverBudget ? "#ef4444" : color,
|
|
885
|
+
}}
|
|
886
|
+
/>
|
|
887
|
+
</div>
|
|
888
|
+
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
889
|
+
<span>{percentage.toFixed(1)}% used</span>
|
|
890
|
+
<span>
|
|
891
|
+
{isOverBudget
|
|
892
|
+
? `${formatValue(category.spent - category.budget)} over`
|
|
893
|
+
: `${formatValue(category.budget - category.spent)} left`}
|
|
894
|
+
</span>
|
|
895
|
+
</div>
|
|
896
|
+
</div>
|
|
897
|
+
)
|
|
898
|
+
})}
|
|
899
|
+
</div>
|
|
900
|
+
</div>
|
|
901
|
+
)
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ============================================================================
|
|
905
|
+
// Alert Banner Component
|
|
906
|
+
// ============================================================================
|
|
907
|
+
|
|
908
|
+
interface AlertBannerProps {
|
|
909
|
+
state: BudgetBurnState
|
|
910
|
+
formatValue: (value: number) => string
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function AlertBanner({ state, formatValue }: AlertBannerProps) {
|
|
914
|
+
if (!state.isDangerZone && !state.isWarningZone) return null
|
|
915
|
+
|
|
916
|
+
return (
|
|
917
|
+
<div className={cn(
|
|
918
|
+
"flex items-center gap-3 px-4 py-3 rounded-lg",
|
|
919
|
+
state.isDangerZone
|
|
920
|
+
? "bg-red-100 dark:bg-red-950 text-red-800 dark:text-red-200"
|
|
921
|
+
: "bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-200"
|
|
922
|
+
)}>
|
|
923
|
+
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
|
924
|
+
<div>
|
|
925
|
+
<div className="font-semibold">
|
|
926
|
+
{state.isDangerZone ? "Budget at Risk!" : "Budget Warning"}
|
|
927
|
+
</div>
|
|
928
|
+
<div className="text-sm opacity-90">
|
|
929
|
+
{state.isDangerZone
|
|
930
|
+
? `At current burn rate of ${formatValue(state.burnRate)}/day, budget will deplete ${
|
|
931
|
+
state.depletionDate ? `on ${formatDate(state.depletionDate)}` : "before period ends"
|
|
932
|
+
}.`
|
|
933
|
+
: `Spending is ${((state.burnRate / state.idealBurnRate - 1) * 100).toFixed(0)}% above ideal burn rate. Consider reducing expenses.`}
|
|
934
|
+
</div>
|
|
935
|
+
</div>
|
|
936
|
+
</div>
|
|
937
|
+
)
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ============================================================================
|
|
941
|
+
// Main Component
|
|
942
|
+
// ============================================================================
|
|
943
|
+
|
|
944
|
+
export function WakaBudgetBurn({
|
|
945
|
+
budget,
|
|
946
|
+
startDate,
|
|
947
|
+
endDate,
|
|
948
|
+
spendHistory,
|
|
949
|
+
categories = [],
|
|
950
|
+
currency = "$",
|
|
951
|
+
dangerThreshold = 30,
|
|
952
|
+
warningThreshold = 15,
|
|
953
|
+
showProjection = true,
|
|
954
|
+
showIdealLine = true,
|
|
955
|
+
showCategories = true,
|
|
956
|
+
showTrends = true,
|
|
957
|
+
animated = true,
|
|
958
|
+
height = 300,
|
|
959
|
+
formatValue: customFormatValue,
|
|
960
|
+
onDangerZone,
|
|
961
|
+
onBudgetDepleted,
|
|
962
|
+
className,
|
|
963
|
+
}: WakaBudgetBurnProps) {
|
|
964
|
+
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
965
|
+
const [containerWidth, setContainerWidth] = React.useState(600)
|
|
966
|
+
|
|
967
|
+
// Calculate burn state
|
|
968
|
+
const state = React.useMemo(
|
|
969
|
+
() => calculateBurnState(budget, startDate, endDate, spendHistory, categories, dangerThreshold, warningThreshold),
|
|
970
|
+
[budget, startDate, endDate, spendHistory, categories, dangerThreshold, warningThreshold]
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
// Default value formatter
|
|
974
|
+
const defaultFormatValue = React.useCallback(
|
|
975
|
+
(value: number) => {
|
|
976
|
+
if (value >= 1000000) {
|
|
977
|
+
return `${currency}${(value / 1000000).toFixed(1)}M`
|
|
978
|
+
}
|
|
979
|
+
if (value >= 1000) {
|
|
980
|
+
return `${currency}${(value / 1000).toFixed(1)}K`
|
|
981
|
+
}
|
|
982
|
+
return `${currency}${value.toFixed(2)}`
|
|
983
|
+
},
|
|
984
|
+
[currency]
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
const formatValue = customFormatValue || defaultFormatValue
|
|
988
|
+
|
|
989
|
+
// Track danger zone changes
|
|
990
|
+
const prevDangerZoneRef = React.useRef(state.isDangerZone)
|
|
991
|
+
const prevDepletedRef = React.useRef(state.totalSpent >= state.totalBudget)
|
|
992
|
+
|
|
993
|
+
React.useEffect(() => {
|
|
994
|
+
if (state.isDangerZone && !prevDangerZoneRef.current) {
|
|
995
|
+
onDangerZone?.()
|
|
996
|
+
}
|
|
997
|
+
prevDangerZoneRef.current = state.isDangerZone
|
|
998
|
+
|
|
999
|
+
const isDepleted = state.totalSpent >= state.totalBudget
|
|
1000
|
+
if (isDepleted && !prevDepletedRef.current) {
|
|
1001
|
+
onBudgetDepleted?.()
|
|
1002
|
+
}
|
|
1003
|
+
prevDepletedRef.current = isDepleted
|
|
1004
|
+
}, [state.isDangerZone, state.totalSpent, state.totalBudget, onDangerZone, onBudgetDepleted])
|
|
1005
|
+
|
|
1006
|
+
// Resize observer for responsive chart
|
|
1007
|
+
React.useEffect(() => {
|
|
1008
|
+
if (!containerRef.current) return
|
|
1009
|
+
|
|
1010
|
+
const observer = new ResizeObserver((entries) => {
|
|
1011
|
+
for (const entry of entries) {
|
|
1012
|
+
setContainerWidth(entry.contentRect.width)
|
|
1013
|
+
}
|
|
1014
|
+
})
|
|
1015
|
+
|
|
1016
|
+
observer.observe(containerRef.current)
|
|
1017
|
+
return () => observer.disconnect()
|
|
1018
|
+
}, [])
|
|
1019
|
+
|
|
1020
|
+
return (
|
|
1021
|
+
<div ref={containerRef} className={cn("space-y-6", className)}>
|
|
1022
|
+
{/* Alert Banner */}
|
|
1023
|
+
<AlertBanner state={state} formatValue={formatValue} />
|
|
1024
|
+
|
|
1025
|
+
{/* Stats Panel */}
|
|
1026
|
+
<StatsPanel state={state} formatValue={formatValue} />
|
|
1027
|
+
|
|
1028
|
+
{/* Burn Chart */}
|
|
1029
|
+
<div className="rounded-lg border bg-card p-4">
|
|
1030
|
+
<div className="flex items-center justify-between mb-4">
|
|
1031
|
+
<div className="text-sm font-medium">Budget Burn-down</div>
|
|
1032
|
+
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
1033
|
+
{showIdealLine && (
|
|
1034
|
+
<div className="flex items-center gap-2">
|
|
1035
|
+
<div className="w-6 h-0.5 bg-slate-400" style={{ backgroundImage: "repeating-linear-gradient(90deg, currentColor 0, currentColor 4px, transparent 4px, transparent 8px)" }} />
|
|
1036
|
+
<span>Ideal</span>
|
|
1037
|
+
</div>
|
|
1038
|
+
)}
|
|
1039
|
+
{showProjection && (
|
|
1040
|
+
<div className="flex items-center gap-2">
|
|
1041
|
+
<div className="w-6 h-0.5" style={{ backgroundImage: "repeating-linear-gradient(90deg, #22c55e 0, #22c55e 3px, transparent 3px, transparent 6px)" }} />
|
|
1042
|
+
<span>Projected</span>
|
|
1043
|
+
</div>
|
|
1044
|
+
)}
|
|
1045
|
+
<div className="flex items-center gap-2">
|
|
1046
|
+
<div className="w-6 h-0.5 bg-gradient-to-r from-blue-500 to-green-500 rounded" />
|
|
1047
|
+
<span>Actual</span>
|
|
1048
|
+
</div>
|
|
1049
|
+
</div>
|
|
1050
|
+
</div>
|
|
1051
|
+
<BurnChart
|
|
1052
|
+
state={state}
|
|
1053
|
+
width={containerWidth - 32}
|
|
1054
|
+
height={height}
|
|
1055
|
+
showProjection={showProjection}
|
|
1056
|
+
showIdealLine={showIdealLine}
|
|
1057
|
+
animated={animated}
|
|
1058
|
+
formatValue={formatValue}
|
|
1059
|
+
/>
|
|
1060
|
+
</div>
|
|
1061
|
+
|
|
1062
|
+
{/* Trends */}
|
|
1063
|
+
{showTrends && spendHistory.length > 0 && (
|
|
1064
|
+
<TrendIndicators state={state} formatValue={formatValue} />
|
|
1065
|
+
)}
|
|
1066
|
+
|
|
1067
|
+
{/* Category Breakdown */}
|
|
1068
|
+
{showCategories && categories.length > 0 && (
|
|
1069
|
+
<div className="rounded-lg border bg-card p-4">
|
|
1070
|
+
<CategoryBreakdown categories={categories} formatValue={formatValue} />
|
|
1071
|
+
</div>
|
|
1072
|
+
)}
|
|
1073
|
+
</div>
|
|
1074
|
+
)
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// ============================================================================
|
|
1078
|
+
// Custom Hook: useBudgetBurn
|
|
1079
|
+
// ============================================================================
|
|
1080
|
+
|
|
1081
|
+
export interface UseBudgetBurnOptions {
|
|
1082
|
+
/** Total budget amount */
|
|
1083
|
+
budget: number
|
|
1084
|
+
/** Budget period start date */
|
|
1085
|
+
startDate: Date
|
|
1086
|
+
/** Budget period end date */
|
|
1087
|
+
endDate: Date
|
|
1088
|
+
/** Initial spending history */
|
|
1089
|
+
initialHistory?: SpendDataPoint[]
|
|
1090
|
+
/** Initial categories */
|
|
1091
|
+
initialCategories?: BudgetCategory[]
|
|
1092
|
+
/** Danger threshold percentage */
|
|
1093
|
+
dangerThreshold?: number
|
|
1094
|
+
/** Warning threshold percentage */
|
|
1095
|
+
warningThreshold?: number
|
|
1096
|
+
/** Callback when danger zone is entered */
|
|
1097
|
+
onDangerZone?: () => void
|
|
1098
|
+
/** Callback when budget is depleted */
|
|
1099
|
+
onBudgetDepleted?: () => void
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
export function useBudgetBurn({
|
|
1103
|
+
budget,
|
|
1104
|
+
startDate,
|
|
1105
|
+
endDate,
|
|
1106
|
+
initialHistory = [],
|
|
1107
|
+
initialCategories = [],
|
|
1108
|
+
dangerThreshold = 30,
|
|
1109
|
+
warningThreshold = 15,
|
|
1110
|
+
onDangerZone,
|
|
1111
|
+
onBudgetDepleted,
|
|
1112
|
+
}: UseBudgetBurnOptions) {
|
|
1113
|
+
const [spendHistory, setSpendHistory] = React.useState<SpendDataPoint[]>(initialHistory)
|
|
1114
|
+
const [categories, setCategories] = React.useState<BudgetCategory[]>(initialCategories)
|
|
1115
|
+
|
|
1116
|
+
// Calculate state
|
|
1117
|
+
const state = React.useMemo(
|
|
1118
|
+
() => calculateBurnState(budget, startDate, endDate, spendHistory, categories, dangerThreshold, warningThreshold),
|
|
1119
|
+
[budget, startDate, endDate, spendHistory, categories, dangerThreshold, warningThreshold]
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
// Track danger zone and depletion
|
|
1123
|
+
const prevDangerZoneRef = React.useRef(state.isDangerZone)
|
|
1124
|
+
const prevDepletedRef = React.useRef(state.totalSpent >= state.totalBudget)
|
|
1125
|
+
|
|
1126
|
+
React.useEffect(() => {
|
|
1127
|
+
if (state.isDangerZone && !prevDangerZoneRef.current) {
|
|
1128
|
+
onDangerZone?.()
|
|
1129
|
+
}
|
|
1130
|
+
prevDangerZoneRef.current = state.isDangerZone
|
|
1131
|
+
|
|
1132
|
+
const isDepleted = state.totalSpent >= state.totalBudget
|
|
1133
|
+
if (isDepleted && !prevDepletedRef.current) {
|
|
1134
|
+
onBudgetDepleted?.()
|
|
1135
|
+
}
|
|
1136
|
+
prevDepletedRef.current = isDepleted
|
|
1137
|
+
}, [state.isDangerZone, state.totalSpent, state.totalBudget, onDangerZone, onBudgetDepleted])
|
|
1138
|
+
|
|
1139
|
+
// Add spend entry
|
|
1140
|
+
const addSpend = React.useCallback((date: Date, amount: number, categoryBreakdown?: Record<string, number>) => {
|
|
1141
|
+
setSpendHistory((prev) => [
|
|
1142
|
+
...prev,
|
|
1143
|
+
{ date, amount, categories: categoryBreakdown },
|
|
1144
|
+
])
|
|
1145
|
+
|
|
1146
|
+
// Update categories if provided
|
|
1147
|
+
if (categoryBreakdown) {
|
|
1148
|
+
setCategories((prev) =>
|
|
1149
|
+
prev.map((cat) => ({
|
|
1150
|
+
...cat,
|
|
1151
|
+
spent: cat.spent + (categoryBreakdown[cat.id] || 0),
|
|
1152
|
+
}))
|
|
1153
|
+
)
|
|
1154
|
+
}
|
|
1155
|
+
}, [])
|
|
1156
|
+
|
|
1157
|
+
// Remove spend entry
|
|
1158
|
+
const removeSpend = React.useCallback((date: Date) => {
|
|
1159
|
+
setSpendHistory((prev) => {
|
|
1160
|
+
const entry = prev.find((p) => p.date.getTime() === date.getTime())
|
|
1161
|
+
if (entry?.categories) {
|
|
1162
|
+
setCategories((cats) =>
|
|
1163
|
+
cats.map((cat) => ({
|
|
1164
|
+
...cat,
|
|
1165
|
+
spent: Math.max(0, cat.spent - (entry.categories?.[cat.id] || 0)),
|
|
1166
|
+
}))
|
|
1167
|
+
)
|
|
1168
|
+
}
|
|
1169
|
+
return prev.filter((p) => p.date.getTime() !== date.getTime())
|
|
1170
|
+
})
|
|
1171
|
+
}, [])
|
|
1172
|
+
|
|
1173
|
+
// Update category
|
|
1174
|
+
const updateCategory = React.useCallback((id: string, updates: Partial<BudgetCategory>) => {
|
|
1175
|
+
setCategories((prev) =>
|
|
1176
|
+
prev.map((cat) => (cat.id === id ? { ...cat, ...updates } : cat))
|
|
1177
|
+
)
|
|
1178
|
+
}, [])
|
|
1179
|
+
|
|
1180
|
+
// Add category
|
|
1181
|
+
const addCategory = React.useCallback((category: BudgetCategory) => {
|
|
1182
|
+
setCategories((prev) => [...prev, category])
|
|
1183
|
+
}, [])
|
|
1184
|
+
|
|
1185
|
+
// Remove category
|
|
1186
|
+
const removeCategory = React.useCallback((id: string) => {
|
|
1187
|
+
setCategories((prev) => prev.filter((cat) => cat.id !== id))
|
|
1188
|
+
}, [])
|
|
1189
|
+
|
|
1190
|
+
// Reset all spending
|
|
1191
|
+
const reset = React.useCallback(() => {
|
|
1192
|
+
setSpendHistory([])
|
|
1193
|
+
setCategories((prev) => prev.map((cat) => ({ ...cat, spent: 0 })))
|
|
1194
|
+
}, [])
|
|
1195
|
+
|
|
1196
|
+
// Get forecast
|
|
1197
|
+
const getForecast = React.useCallback((days: number) => {
|
|
1198
|
+
const projectedSpend = state.totalSpent + state.burnRate * days
|
|
1199
|
+
const projectedRemaining = Math.max(0, budget - projectedSpend)
|
|
1200
|
+
const projectedPercentUsed = Math.min(100, (projectedSpend / budget) * 100)
|
|
1201
|
+
|
|
1202
|
+
return {
|
|
1203
|
+
projectedSpend,
|
|
1204
|
+
projectedRemaining,
|
|
1205
|
+
projectedPercentUsed,
|
|
1206
|
+
willDeplete: projectedSpend >= budget,
|
|
1207
|
+
daysUntilDepletion: state.burnRate > 0
|
|
1208
|
+
? Math.ceil((budget - state.totalSpent) / state.burnRate)
|
|
1209
|
+
: Infinity,
|
|
1210
|
+
}
|
|
1211
|
+
}, [budget, state.totalSpent, state.burnRate])
|
|
1212
|
+
|
|
1213
|
+
return {
|
|
1214
|
+
// State
|
|
1215
|
+
...state,
|
|
1216
|
+
spendHistory,
|
|
1217
|
+
categories,
|
|
1218
|
+
|
|
1219
|
+
// Actions
|
|
1220
|
+
addSpend,
|
|
1221
|
+
removeSpend,
|
|
1222
|
+
updateCategory,
|
|
1223
|
+
addCategory,
|
|
1224
|
+
removeCategory,
|
|
1225
|
+
reset,
|
|
1226
|
+
setSpendHistory,
|
|
1227
|
+
setCategories,
|
|
1228
|
+
|
|
1229
|
+
// Utilities
|
|
1230
|
+
getForecast,
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
export default WakaBudgetBurn
|