Commit 074ca247 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/budget-recurrence-and-report-enhancements' into 'develop'

feat(reports,budgets): enhance cash flow chart, category drilldown, and budget auto-renewal UI

See merge request !38
parents 802420fa a9cc61ef
...@@ -28,9 +28,10 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -28,9 +28,10 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- **Quản lý Routing**: Sử dụng cấu hình router của ZMP UI / React Router tích hợp bên trong template để dẫn hướng giữa các màn hình nghiệp vụ và `/style-guide`. - **Quản lý Routing**: Sử dụng cấu hình router của ZMP UI / React Router tích hợp bên trong template để dẫn hướng giữa các màn hình nghiệp vụ và `/style-guide`.
- **Hiển thị Category**: Trang quản lý danh mục tại `/categories` dùng `GET /categories/tree` làm nguồn hiển thị chính, giữ cấu trúc cha/con và sắp xếp đệ quy ở client vì tree endpoint không nhận tham số sort. Danh mục hệ thống là chỉ đọc và tên hiển thị được ánh xạ theo locale mà không thay đổi payload API; danh mục cá nhân giữ nguyên tên người dùng nhập và hỗ trợ tạo/sửa, archive cả nhánh, restore. Parent picker chỉ cho chọn danh mục đang hoạt động, cùng loại giao dịch và loại trừ chính node cùng toàn bộ hậu duệ để tránh chu trình. - **Hiển thị Category**: Trang quản lý danh mục tại `/categories` dùng `GET /categories/tree` làm nguồn hiển thị chính, giữ cấu trúc cha/con và sắp xếp đệ quy ở client vì tree endpoint không nhận tham số sort. Danh mục hệ thống là chỉ đọc và tên hiển thị được ánh xạ theo locale mà không thay đổi payload API; danh mục cá nhân giữ nguyên tên người dùng nhập và hỗ trợ tạo/sửa, archive cả nhánh, restore. Parent picker chỉ cho chọn danh mục đang hoạt động, cùng loại giao dịch và loại trừ chính node cùng toàn bộ hậu duệ để tránh chu trình.
- **Quản lý Budget**: Trang `/budgets``/budgets/:id` dùng trực tiếp usage do Budget API tính (`spentAmount`, `remainingAmount`, `usagePercentage`, `timeStatus`, `status`) để giữ một nguồn sự thật chung cho UI, báo cáo, thông báo và AI. `DELETE /budgets/:id` là archive/xóa mềm nhằm bảo toàn lịch sử; ngân sách đã archive chỉ đọc đến khi restore. Mọi mutation giao dịch phải invalidate cache `budgets` vì số tiền đã chi phụ thuộc giao dịch EXPENSE. - **Quản lý Budget**: Trang `/budgets``/budgets/:id` dùng trực tiếp usage do Budget API tính (`spentAmount`, `remainingAmount`, `usagePercentage`, `timeStatus`, `status`) để giữ một nguồn sự thật chung cho UI, báo cáo, thông báo và AI. `DELETE /budgets/:id` là archive/xóa mềm nhằm bảo toàn lịch sử; ngân sách đã archive chỉ đọc đến khi restore. Mọi mutation giao dịch phải invalidate cache `budgets` vì số tiền đã chi phụ thuộc giao dịch EXPENSE.
Hỗ trợ tính năng ngân sách định kỳ tự động gia hạn (`isRecurring`, `autoRenew`, `rolloverMode`): cấu hình chu kỳ lặp và chính sách cộng dồn/trừ số dư tại `BudgetFormModal`, huy hiệu trạng thái gia hạn và số tiền rollover tại `BudgetCard`, nút bật/tắt tạm dừng gia hạn tại `BudgetDetailPage`.
- **Quản lý Saving Goal**: Trang `/saving-goals``/saving-goals/:id` dùng trực tiếp progress do Saving Goal API tính (`savedAmount`, `remainingAmount`, `progressPercentage`, `daysRemaining`, `isOverdue`) để giữ một nguồn sự thật chung cho UI, báo cáo và AI. `DELETE /saving-goals/:id` là archive/xóa mềm nhằm bảo toàn lịch sử; mục tiêu archive chỉ đọc đến khi restore. Contribution được quản lý riêng và mọi mutation contribution phải làm mới cả detail, list goal và lịch sử contribution. - **Quản lý Saving Goal**: Trang `/saving-goals``/saving-goals/:id` dùng trực tiếp progress do Saving Goal API tính (`savedAmount`, `remainingAmount`, `progressPercentage`, `daysRemaining`, `isOverdue`) để giữ một nguồn sự thật chung cho UI, báo cáo và AI. `DELETE /saving-goals/:id` là archive/xóa mềm nhằm bảo toàn lịch sử; mục tiêu archive chỉ đọc đến khi restore. Contribution được quản lý riêng và mọi mutation contribution phải làm mới cả detail, list goal và lịch sử contribution.
- **Báo cáo tài chính**: Trang `/reports` dùng trực tiếp bốn Report API (`overview`, `cash-flow`, `spending-by-category`, `budget-performance`) qua TanStack Query và luôn giữ số liệu tách theo tiền tệ; không cộng gộp các loại tiền tệ khác nhau. Bộ lọc khoảng ngày tùy chọn gửi mốc kết thúc dạng exclusive sang Backend nhưng hiển thị ngày kết thúc dạng inclusive cho người dùng. Biểu đồ dùng SVG/CSS với semantic Clay tokens, không phụ thuộc thư viện chart. Thẻ Tổng quan ngân sách phải thể hiện số liệu tổng hợp (đã chi, còn lại, hạn mức và số lượng theo trạng thái), còn Hiệu suất ngân sách trình bày từng ngân sách; tổng quan chỉ ưu tiên summary `OVERALL` khi `budgetCount > 0`, nếu không fallback sang `CATEGORY`, và hiển thị `—` thay vì `0%` khi không có ngân sách hợp lệ. Khối AI tại báo cáo phải hiển thị trọn bản tóm tắt cùng tối đa ba điểm đáng chú ý, sau đó mới dẫn sang Trợ lý AI để phân tích sâu; khi điều hướng phải giữ `dateFrom`/`dateTo`/`currency` của kỳ báo cáo và nút quay lại phải trở về báo cáo. - **Báo cáo tài chính**: Trang `/reports` dùng trực tiếp bốn Report API (`overview`, `cash-flow`, `spending-by-category`, `budget-performance`) qua TanStack Query và luôn giữ số liệu tách theo tiền tệ; không cộng gộp các loại tiền tệ khác nhau. Bộ lọc khoảng ngày tùy chọn gửi mốc kết thúc dạng exclusive sang Backend nhưng hiển thị ngày kết thúc dạng inclusive cho người dùng. Biểu đồ dùng SVG/CSS với semantic Clay tokens, không phụ thuộc thư viện chart. Biểu đồ Xu hướng dòng tiền hiển thị số tiền thu/chi gọn gàng trực tiếp tại các điểm nút trên đường cong (không dùng cột số tiền bên trái để tối ưu diện tích vẽ đồ thị), tự động tách vị trí trên/dưới khi có giao dịch cả thu và chi; trục hoành chia mốc chuẩn: Tuần chia 7 ngày từ Thứ 2 đến CN, Năm chia 12 tháng (T1-T12), hỗ trợ chạm vào điểm để highlight và xem chi tiết. Thẻ Tổng quan ngân sách phải thể hiện số liệu tổng hợp (đã chi, còn lại, hạn mức và số lượng theo trạng thái), còn Hiệu suất ngân sách trình bày từng ngân sách; tổng quan chỉ ưu tiên summary `OVERALL` khi `budgetCount > 0`, nếu không fallback sang `CATEGORY`, và hiển thị `—` thay vì `0%` khi không có ngân sách hợp lệ. Khối AI tại báo cáo phải hiển thị trọn bản tóm tắt cùng tối đa ba điểm đáng chú ý, sau đó mới dẫn sang Trợ lý AI để phân tích sâu; khi điều hướng phải giữ `dateFrom`/`dateTo`/`currency` của kỳ báo cáo và nút quay lại phải trở về báo cáo.
- Các bảng/thẻ tổng hợp trên `/reports` liên kết tới module tương ứng: số dư và từng ví tới Ví, thu/chi và dòng tiền tới Giao dịch, phân bổ chi tiêu tới Danh mục, ngân sách tới chi tiết ngân sách, mục tiêu tới danh sách mục tiêu. Chỉ bật trạng thái tương tác khi người dùng có quyền đọc route đích; route đích nhận marker `fromReports` để nút quay lại trở về đúng trang báo cáo. Thẻ Tổng thu/Tổng chi mở danh sách giao dịch với bộ lọc loại `INCOME`/`EXPENSE` tương ứng và tự mở bảng lọc để người dùng nhận biết trạng thái đang áp dụng. - Các bảng/thẻ tổng hợp trên `/reports` liên kết tới module tương ứng: số dư và từng ví tới Ví, thu/chi, dòng tiền và chi tiêu theo danh mục tới Giao dịch (chọn danh mục cụ thể sẽ lọc theo đúng danh mục đó, loại chi tiêu và khoảng ngày của kỳ báo cáo), ngân sách tới chi tiết ngân sách, mục tiêu tới danh sách mục tiêu. Chỉ bật trạng thái tương tác khi người dùng có quyền đọc route đích; route đích nhận marker `fromReports` để nút quay lại trở về đúng trang báo cáo. Thẻ Tổng thu/Tổng chi và Chi tiêu theo danh mục mở danh sách giao dịch với các bộ lọc tương ứng và tự mở bảng lọc để người dùng nhận biết trạng thái đang áp dụng.
- **Thông báo & nhắc nhở**: Trang `/notifications` là trung tâm chung cho inbox thông báo, lịch nhắc và tùy chọn cảnh báo. Unread count dùng query key riêng và được hiển thị bằng badge chuông toàn cục sau đăng nhập. Cơ chế short polling 30 giây đã được thay thế hoàn toàn bằng Server-Sent Events (SSE) qua hook `useNotificationSSE` (kết nối tới `/notifications/stream` với token/cookie) kết hợp `NotificationRealtimeListener` trong `Layout`; khi có thông báo mới hoặc unread count thay đổi, server tự động đẩy event tới client để cập nhật badge và hiển thị popup tức thì; refetch khi focus và mọi mutation invalidate namespace liên quan. Reminder datetime luôn dùng Vietnamese wall time rồi chuyển sang UTC ISO bằng helper business-time. Với cảnh báo tự động do Backend hiện tạo bằng tiếng Anh, frontend nhận diện đúng mẫu theo `type`, dùng metadata để dựng title/message theo locale; nội dung người dùng nhập, legacy không khớp mẫu và AI tương lai được giữ nguyên. Notification `data` giữ kiểu mở `Record<string, unknown>` để tiếp nhận metadata cảnh báo/AI mà UI không phụ thuộc cấu trúc riêng. Điều hướng từ nút xem chi tiết gắn `fromNotifications` trong route state; các trang đích hệ thống dùng history back khi có marker này và giữ fallback back cố định cho luồng truy cập trực tiếp. Thông báo giao dịch bất thường phải truyền `transactionId` (fallback lần lượt từ `data`, `sourceId`, URL) để trang Giao dịch gọi chi tiết và tự mở đúng giao dịch, không chỉ chuyển tới danh sách chung. - **Thông báo & nhắc nhở**: Trang `/notifications` là trung tâm chung cho inbox thông báo, lịch nhắc và tùy chọn cảnh báo. Unread count dùng query key riêng và được hiển thị bằng badge chuông toàn cục sau đăng nhập. Cơ chế short polling 30 giây đã được thay thế hoàn toàn bằng Server-Sent Events (SSE) qua hook `useNotificationSSE` (kết nối tới `/notifications/stream` với token/cookie) kết hợp `NotificationRealtimeListener` trong `Layout`; khi có thông báo mới hoặc unread count thay đổi, server tự động đẩy event tới client để cập nhật badge và hiển thị popup tức thì; refetch khi focus và mọi mutation invalidate namespace liên quan. Reminder datetime luôn dùng Vietnamese wall time rồi chuyển sang UTC ISO bằng helper business-time. Với cảnh báo tự động do Backend hiện tạo bằng tiếng Anh, frontend nhận diện đúng mẫu theo `type`, dùng metadata để dựng title/message theo locale; nội dung người dùng nhập, legacy không khớp mẫu và AI tương lai được giữ nguyên. Notification `data` giữ kiểu mở `Record<string, unknown>` để tiếp nhận metadata cảnh báo/AI mà UI không phụ thuộc cấu trúc riêng. Điều hướng từ nút xem chi tiết gắn `fromNotifications` trong route state; các trang đích hệ thống dùng history back khi có marker này và giữ fallback back cố định cho luồng truy cập trực tiếp. Thông báo giao dịch bất thường phải truyền `transactionId` (fallback lần lượt từ `data`, `sourceId`, URL) để trang Giao dịch gọi chi tiết và tự mở đúng giao dịch, không chỉ chuyển tới danh sách chung.
- **Giao dịch tự động định kỳ**: Trang `/recurring-transactions` quản lý lịch thu/chi tự động qua - **Giao dịch tự động định kỳ**: Trang `/recurring-transactions` quản lý lịch thu/chi tự động qua
TanStack Query, gồm tạo/sửa, pause/resume, xóa mềm, preview ngày chạy và lịch sử occurrence. TanStack Query, gồm tạo/sửa, pause/resume, xóa mềm, preview ngày chạy và lịch sử occurrence.
......
...@@ -8,6 +8,7 @@ export const budgetKeys = { ...@@ -8,6 +8,7 @@ export const budgetKeys = {
list: (query: BudgetQuery) => [...budgetKeys.lists(), query] as const, list: (query: BudgetQuery) => [...budgetKeys.lists(), query] as const,
details: () => [...budgetKeys.all, "detail"] as const, details: () => [...budgetKeys.all, "detail"] as const,
detail: (id: string) => [...budgetKeys.details(), id] as const, detail: (id: string) => [...budgetKeys.details(), id] as const,
series: (id: string) => [...budgetKeys.all, "series", id] as const,
}; };
export function useBudgets(query: BudgetQuery) { export function useBudgets(query: BudgetQuery) {
...@@ -32,6 +33,15 @@ export function useBudget(id: string) { ...@@ -32,6 +33,15 @@ export function useBudget(id: string) {
}); });
} }
export function useBudgetSeries(id: string) {
return useQuery({
queryKey: budgetKeys.series(id),
queryFn: () => budgetService.getBudgetSeries(id),
enabled: Boolean(id),
staleTime: 30_000,
});
}
export function useCreateBudget() { export function useCreateBudget() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
...@@ -49,6 +59,19 @@ export function useUpdateBudget(id: string) { ...@@ -49,6 +59,19 @@ export function useUpdateBudget(id: string) {
onSuccess: async (response) => { onSuccess: async (response) => {
queryClient.setQueryData(budgetKeys.detail(id), response); queryClient.setQueryData(budgetKeys.detail(id), response);
await queryClient.invalidateQueries({ queryKey: budgetKeys.lists() }); await queryClient.invalidateQueries({ queryKey: budgetKeys.lists() });
await queryClient.invalidateQueries({ queryKey: budgetKeys.series(id) });
},
});
}
export function useToggleBudgetAutoRenew(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (autoRenew: boolean) => budgetService.toggleAutoRenew(id, autoRenew),
onSuccess: async (response) => {
queryClient.setQueryData(budgetKeys.detail(id), response);
await queryClient.invalidateQueries({ queryKey: budgetKeys.lists() });
await queryClient.invalidateQueries({ queryKey: budgetKeys.series(id) });
}, },
}); });
} }
......
...@@ -614,6 +614,33 @@ ...@@ -614,6 +614,33 @@
"nameAsc": "Name A → Z", "nameAsc": "Name A → Z",
"updatedDesc": "Recently updated" "updatedDesc": "Recently updated"
}, },
"autoRenewActive": "Auto-renew",
"autoRenewPaused": "Auto-renew paused",
"autoRenewTitle": "Cycle Auto-Renewal",
"autoRenewActiveDesc": "System will automatically create a new budget for the next cycle.",
"autoRenewPausedDesc": "Auto-renewal is paused. Budget will not automatically renew for the next cycle.",
"pauseAutoRenew": "Pause auto-renew",
"resumeAutoRenew": "Enable auto-renew",
"autoRenewResumed": "Auto-renewal enabled for this budget.",
"autoRenewPausedMsg": "Auto-renewal paused for this budget.",
"toggleRenewFailed": "Could not update auto-renewal status.",
"rolloverPolicy": "Rollover Policy",
"rolloverAmount": "Rollover from previous cycle",
"seriesHistory": "Cycle History",
"fromPrevious": "from previous cycle",
"rollover": {
"reset": "Base limit",
"resetDesc": "Start fresh with the original limit each cycle.",
"rollover_surplus": "Rollover surplus",
"surplus": "Rollover surplus",
"surplusDesc": "Add unspent balance from previous cycle to current limit.",
"rollover_deficit": "Deduct deficit",
"deficit": "Deduct deficit",
"deficitDesc": "Subtract overspent amount from current limit.",
"rollover_net": "Net rollover",
"net": "Net rollover",
"netDesc": "Carry forward either surplus (added) or deficit (deducted)."
},
"form": { "form": {
"createTitle": "Create budget", "createTitle": "Create budget",
"editTitle": "Edit budget", "editTitle": "Edit budget",
...@@ -631,6 +658,9 @@ ...@@ -631,6 +658,9 @@
"startDate": "Start date *", "startDate": "Start date *",
"endDate": "End date *", "endDate": "End date *",
"autoEndDateHint": "The system calculates the end date from the selected period.", "autoEndDateHint": "The system calculates the end date from the selected period.",
"autoRenew": "Automatically renew for next cycle",
"autoRenewHint": "The system will automatically generate a new budget when this cycle ends.",
"rolloverModeLabel": "Budget balance policy on renewal:",
"alertThreshold": "Alert threshold", "alertThreshold": "Alert threshold",
"alertThresholdHint": "Used by budget alerts and notifications.", "alertThresholdHint": "Used by budget alerts and notifications.",
"decreaseThreshold": "Decrease alert threshold", "decreaseThreshold": "Decrease alert threshold",
......
...@@ -640,6 +640,33 @@ ...@@ -640,6 +640,33 @@
"nameAsc": "Tên A → Z", "nameAsc": "Tên A → Z",
"updatedDesc": "Mới cập nhật" "updatedDesc": "Mới cập nhật"
}, },
"autoRenewActive": "Tự động gia hạn",
"autoRenewPaused": "Tạm dừng gia hạn",
"autoRenewTitle": "Tự động gia hạn chu kỳ",
"autoRenewActiveDesc": "Hệ thống sẽ tự động tạo ngân sách mới cho chu kỳ tiếp theo.",
"autoRenewPausedDesc": "Tự động gia hạn đang tạm dừng. Ngân sách sẽ không tự tạo cho chu kỳ sau.",
"pauseAutoRenew": "Tạm dừng gia hạn",
"resumeAutoRenew": "Bật tự động gia hạn",
"autoRenewResumed": "Đã kích hoạt tự động gia hạn cho ngân sách này.",
"autoRenewPausedMsg": "Đã tạm dừng tự động gia hạn.",
"toggleRenewFailed": "Không thể thay đổi trạng thái tự động gia hạn.",
"rolloverPolicy": "Chính sách số dư",
"rolloverAmount": "Cộng dồn từ kỳ trước",
"seriesHistory": "Lịch sử các chu kỳ",
"fromPrevious": "từ kỳ trước",
"rollover": {
"reset": "Mức gốc",
"resetDesc": "Bắt đầu lại với hạn mức ban đầu mỗi chu kỳ.",
"rollover_surplus": "Cộng dồn dư",
"surplus": "Cộng dồn dư",
"surplusDesc": "Cộng thêm tiền thừa từ kỳ trước vào hạn mức kỳ sau.",
"rollover_deficit": "Trừ bội chi",
"deficit": "Trừ bội chi",
"deficitDesc": "Khấu trừ phần tiền chi tiêu vượt mức kỳ trước.",
"rollover_net": "Cộng dồn ròng",
"net": "Cộng dồn ròng",
"netDesc": "Cộng dồn cả dư (thêm) hoặc thiếu (trừ) sang kỳ sau."
},
"form": { "form": {
"createTitle": "Tạo ngân sách", "createTitle": "Tạo ngân sách",
"editTitle": "Chỉnh sửa ngân sách", "editTitle": "Chỉnh sửa ngân sách",
...@@ -657,6 +684,9 @@ ...@@ -657,6 +684,9 @@
"startDate": "Ngày bắt đầu *", "startDate": "Ngày bắt đầu *",
"endDate": "Ngày kết thúc *", "endDate": "Ngày kết thúc *",
"autoEndDateHint": "Ngày kết thúc sẽ được hệ thống tự tính theo chu kỳ đã chọn.", "autoEndDateHint": "Ngày kết thúc sẽ được hệ thống tự tính theo chu kỳ đã chọn.",
"autoRenew": "Tự động lặp lại cho chu kỳ tiếp theo",
"autoRenewHint": "Hệ thống sẽ tự động tạo ngân sách mới khi chu kỳ này kết thúc.",
"rolloverModeLabel": "Chính sách số tiền khi gia hạn chu kỳ mới:",
"alertThreshold": "Ngưỡng cảnh báo", "alertThreshold": "Ngưỡng cảnh báo",
"alertThresholdHint": "Dùng cho cảnh báo và thông báo ngân sách.", "alertThresholdHint": "Dùng cho cảnh báo và thông báo ngân sách.",
"decreaseThreshold": "Giảm ngưỡng cảnh báo", "decreaseThreshold": "Giảm ngưỡng cảnh báo",
......
...@@ -7,6 +7,10 @@ export interface FinWiseNavigationState { ...@@ -7,6 +7,10 @@ export interface FinWiseNavigationState {
fromReports?: boolean; fromReports?: boolean;
transactionId?: string; transactionId?: string;
transactionTypeFilter?: TransactionType; transactionTypeFilter?: TransactionType;
categoryIdFilter?: string;
walletIdFilter?: string;
dateFromFilter?: string;
dateToFilter?: string;
tab?: "chat" | "insights"; tab?: "chat" | "insights";
insightsTab?: "insights" | "recommendations"; insightsTab?: "insights" | "recommendations";
analysisScope?: AIAnalysisScope; analysisScope?: AIAnalysisScope;
...@@ -38,3 +42,28 @@ export function getTransactionId(state: unknown): string | undefined { ...@@ -38,3 +42,28 @@ export function getTransactionId(state: unknown): string | undefined {
const value = (state as FinWiseNavigationState).transactionId; const value = (state as FinWiseNavigationState).transactionId;
return typeof value === "string" && value.trim() ? value : undefined; return typeof value === "string" && value.trim() ? value : undefined;
} }
export function getCategoryIdFilter(state: unknown): string | undefined {
if (!state || typeof state !== "object") return undefined;
const value = (state as FinWiseNavigationState).categoryIdFilter;
return typeof value === "string" && value.trim() ? value : undefined;
}
export function getWalletIdFilter(state: unknown): string | undefined {
if (!state || typeof state !== "object") return undefined;
const value = (state as FinWiseNavigationState).walletIdFilter;
return typeof value === "string" && value.trim() ? value : undefined;
}
export function getDateFromFilter(state: unknown): string | undefined {
if (!state || typeof state !== "object") return undefined;
const value = (state as FinWiseNavigationState).dateFromFilter;
return typeof value === "string" && value.trim() ? value : undefined;
}
export function getDateToFilter(state: unknown): string | undefined {
if (!state || typeof state !== "object") return undefined;
const value = (state as FinWiseNavigationState).dateToFilter;
return typeof value === "string" && value.trim() ? value : undefined;
}
import React from "react"; import React from "react";
import { CategoryArtwork } from "@/components/shared/CategoryArtwork"; import { CategoryArtwork } from "@/components/shared/CategoryArtwork";
import { Badge, BadgeType } from "@/components/ui/Badge"; import { Badge, BadgeType } from "@/components/ui/Badge";
import { BudgetIcon } from "@/components/ui/icons"; import { BudgetIcon, RefreshIcon } from "@/components/ui/icons";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { ProgressBar } from "@/components/ui/ProgressBar"; import { ProgressBar } from "@/components/ui/ProgressBar";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
...@@ -56,6 +56,18 @@ export const BudgetCard: React.FC<BudgetCardProps> = ({ budget, onClick }) => { ...@@ -56,6 +56,18 @@ export const BudgetCard: React.FC<BudgetCardProps> = ({ budget, onClick }) => {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h3 className="truncate font-baloo text-lg font-bold text-clay-text">{budget.name}</h3> <h3 className="truncate font-baloo text-lg font-bold text-clay-text">{budget.name}</h3>
{budget.isRecurring && (
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-nunito text-[11px] font-bold ${
budget.autoRenew
? "bg-clay-primary/15 text-clay-primary"
: "bg-clay-warning/15 text-clay-warning"
}`}
>
<RefreshIcon size={11} className="shrink-0" />
{budget.autoRenew ? t("budget.autoRenewActive") : t("budget.autoRenewPaused")}
</span>
)}
{budget.isArchived && <Badge type="info">{t("common.archived")}</Badge>} {budget.isArchived && <Badge type="info">{t("common.archived")}</Badge>}
</div> </div>
<p className="clay-caption truncate">{categoryName} · {t(`budget.period.${budget.period.toLowerCase()}`)}</p> <p className="clay-caption truncate">{categoryName} · {t(`budget.period.${budget.period.toLowerCase()}`)}</p>
...@@ -74,6 +86,12 @@ export const BudgetCard: React.FC<BudgetCardProps> = ({ budget, onClick }) => { ...@@ -74,6 +86,12 @@ export const BudgetCard: React.FC<BudgetCardProps> = ({ budget, onClick }) => {
<p className="mt-1 truncate font-baloo text-sm font-bold text-clay-text"> <p className="mt-1 truncate font-baloo text-sm font-bold text-clay-text">
{formatCurrency(Number(budget.amount), budget.currency)} {formatCurrency(Number(budget.amount), budget.currency)}
</p> </p>
{Number(budget.rolloverAmount) !== 0 && (
<p className="clay-caption mt-0.5 text-[10px] text-clay-primary">
{Number(budget.rolloverAmount) > 0 ? "+" : ""}
{formatCurrency(Number(budget.rolloverAmount), budget.currency)}
</p>
)}
</div> </div>
<div className="rounded-clay-sm bg-clay-expense/10 p-2.5 shadow-clay-pressed"> <div className="rounded-clay-sm bg-clay-expense/10 p-2.5 shadow-clay-pressed">
<p className="font-nunito text-[11px] font-bold text-clay-text-muted">{t("budget.spent")}</p> <p className="font-nunito text-[11px] font-bold text-clay-text-muted">{t("budget.spent")}</p>
......
...@@ -9,6 +9,7 @@ import { Modal } from "@/components/ui/Modal"; ...@@ -9,6 +9,7 @@ import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select"; import { Select } from "@/components/ui/Select";
import { Slider } from "@/components/ui/Slider"; import { Slider } from "@/components/ui/Slider";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal"; import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { RefreshIcon } from "@/components/ui/icons";
import { useCategoryTree } from "@/hooks/use-categories"; import { useCategoryTree } from "@/hooks/use-categories";
import { TranslationFunction, useI18n } from "@/i18n"; import { TranslationFunction, useI18n } from "@/i18n";
import { getCategoryDisplayName } from "@/lib/category-format"; import { getCategoryDisplayName } from "@/lib/category-format";
...@@ -88,6 +89,8 @@ const createSchema = (t: TranslationFunction) => z.object({ ...@@ -88,6 +89,8 @@ const createSchema = (t: TranslationFunction) => z.object({
startDate: z.string().min(1, t("validation.budgetStartDateRequired")), startDate: z.string().min(1, t("validation.budgetStartDateRequired")),
endDate: z.string(), endDate: z.string(),
alertThreshold: z.number().min(1).max(100), alertThreshold: z.number().min(1).max(100),
isRecurring: z.boolean(),
rolloverMode: z.enum(["RESET", "ROLLOVER_SURPLUS", "ROLLOVER_DEFICIT", "ROLLOVER_NET"]),
}).superRefine((values, context) => { }).superRefine((values, context) => {
if (values.type === "CATEGORY" && !values.categoryId) { if (values.type === "CATEGORY" && !values.categoryId) {
context.addIssue({ code: "custom", path: ["categoryId"], message: t("validation.budgetCategoryRequired") }); context.addIssue({ code: "custom", path: ["categoryId"], message: t("validation.budgetCategoryRequired") });
...@@ -130,6 +133,8 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({ ...@@ -130,6 +133,8 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
startDate: localDate(budget?.startDate), startDate: localDate(budget?.startDate),
endDate: budget?.period === "CUSTOM" ? localDate(budget.endDate) : "", endDate: budget?.period === "CUSTOM" ? localDate(budget.endDate) : "",
alertThreshold: Number(budget?.alertThreshold || 80), alertThreshold: Number(budget?.alertThreshold || 80),
isRecurring: budget ? Boolean(budget.isRecurring) : true,
rolloverMode: budget?.rolloverMode || "RESET",
}), [budget]); }), [budget]);
const { const {
...@@ -179,6 +184,7 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({ ...@@ -179,6 +184,7 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
}, [budget, categoriesQuery.data?.data, t]); }, [budget, categoriesQuery.data?.data, t]);
const submitForm = (values: BudgetFormValues) => { const submitForm = (values: BudgetFormValues) => {
const isRecurring = values.period !== "CUSTOM" && values.isRecurring;
onSubmit({ onSubmit({
name: values.name.trim(), name: values.name.trim(),
amount: values.amount, amount: values.amount,
...@@ -189,6 +195,9 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({ ...@@ -189,6 +195,9 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
startDate: toIsoDate(values.startDate), startDate: toIsoDate(values.startDate),
...(values.period === "CUSTOM" ? { endDate: toIsoDate(values.endDate) } : {}), ...(values.period === "CUSTOM" ? { endDate: toIsoDate(values.endDate) } : {}),
alertThreshold: String(values.alertThreshold), alertThreshold: String(values.alertThreshold),
isRecurring,
autoRenew: isRecurring,
rolloverMode: isRecurring ? values.rolloverMode : "RESET",
}); });
}; };
...@@ -307,6 +316,68 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({ ...@@ -307,6 +316,68 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
</div> </div>
{selectedPeriod !== "CUSTOM" && <p className="-mt-2 px-1 clay-caption">{t("budget.form.autoEndDateHint")}</p>} {selectedPeriod !== "CUSTOM" && <p className="-mt-2 px-1 clay-caption">{t("budget.form.autoEndDateHint")}</p>}
{selectedPeriod !== "CUSTOM" && (
<div className="flex flex-col gap-3 rounded-clay-sm bg-clay-bg p-4 shadow-clay-pressed">
<label className="flex cursor-pointer items-center justify-between">
<div className="pr-3">
<div className="flex items-center gap-2">
<span className="font-nunito text-sm font-bold text-clay-text">
{t("budget.form.autoRenew")}
</span>
<RefreshIcon size={14} className="shrink-0 text-clay-primary" />
</div>
<p className="clay-caption mt-0.5">
{t("budget.form.autoRenewHint")}
</p>
</div>
<input
type="checkbox"
className="h-5 w-5 cursor-pointer accent-clay-primary rounded"
{...register("isRecurring")}
/>
</label>
{watch("isRecurring") && (
<div className="mt-1 flex flex-col gap-2 border-t border-clay-border/40 pt-3">
<p className="font-nunito text-xs font-bold text-clay-text-muted">
{t("budget.form.rolloverModeLabel")}
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{[
{ mode: "RESET", label: t("budget.rollover.reset"), desc: t("budget.rollover.resetDesc") },
{ mode: "ROLLOVER_SURPLUS", label: t("budget.rollover.surplus"), desc: t("budget.rollover.surplusDesc") },
{ mode: "ROLLOVER_NET", label: t("budget.rollover.net"), desc: t("budget.rollover.netDesc") },
].map((item) => (
<label
key={item.mode}
className={`flex cursor-pointer flex-col gap-1 rounded-clay-sm p-2.5 transition-all duration-200 border ${
watch("rolloverMode") === item.mode
? "bg-clay-primary/15 border-clay-primary shadow-clay-raised"
: "bg-clay-surface border-transparent shadow-clay-pressed"
}`}
>
<div className="flex items-center gap-2">
<input
type="radio"
value={item.mode}
className="h-4 w-4 accent-clay-primary"
{...register("rolloverMode")}
/>
<span className="font-nunito text-xs font-bold text-clay-text">
{item.label}
</span>
</div>
<span className="clay-caption text-[11px] leading-tight">
{item.desc}
</span>
</label>
))}
</div>
</div>
)}
</div>
)}
<Controller <Controller
name="alertThreshold" name="alertThreshold"
control={control} control={control}
......
...@@ -6,11 +6,12 @@ import { Button } from "@/components/ui/Button"; ...@@ -6,11 +6,12 @@ import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { ProgressBar } from "@/components/ui/ProgressBar"; import { ProgressBar } from "@/components/ui/ProgressBar";
import { BudgetIcon, IconGradients } from "@/components/ui/icons"; import { BudgetIcon, IconGradients, RefreshIcon } from "@/components/ui/icons";
import { import {
useArchiveBudget, useArchiveBudget,
useBudget, useBudget,
useRestoreBudget, useRestoreBudget,
useToggleBudgetAutoRenew,
useUpdateBudget, useUpdateBudget,
} from "@/hooks/use-budgets"; } from "@/hooks/use-budgets";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
...@@ -40,6 +41,7 @@ const BudgetDetailPage: React.FC = () => { ...@@ -40,6 +41,7 @@ const BudgetDetailPage: React.FC = () => {
const updateMutation = useUpdateBudget(budgetId); const updateMutation = useUpdateBudget(budgetId);
const archiveMutation = useArchiveBudget(); const archiveMutation = useArchiveBudget();
const restoreMutation = useRestoreBudget(); const restoreMutation = useRestoreBudget();
const toggleAutoRenewMutation = useToggleBudgetAutoRenew(budgetId);
const budget = budgetQuery.data?.data; const budget = budgetQuery.data?.data;
const isActionPending = archiveMutation.isPending || restoreMutation.isPending; const isActionPending = archiveMutation.isPending || restoreMutation.isPending;
...@@ -47,6 +49,20 @@ const BudgetDetailPage: React.FC = () => { ...@@ -47,6 +49,20 @@ const BudgetDetailPage: React.FC = () => {
openSnackbar({ type: "error", text: getErrorMessage(error, fallback) }); openSnackbar({ type: "error", text: getErrorMessage(error, fallback) });
}; };
const handleToggleAutoRenew = () => {
if (!budget) return;
const nextState = !budget.autoRenew;
toggleAutoRenewMutation.mutate(nextState, {
onSuccess: () => {
openSnackbar({
type: "success",
text: nextState ? t("budget.autoRenewResumed") : t("budget.autoRenewPausedMsg"),
});
},
onError: (error) => showError(error, t("budget.toggleRenewFailed")),
});
};
const handleUpdate = (input: CreateBudgetInput) => { const handleUpdate = (input: CreateBudgetInput) => {
updateMutation.mutate(input, { updateMutation.mutate(input, {
onSuccess: () => { onSuccess: () => {
...@@ -177,6 +193,50 @@ const BudgetDetailPage: React.FC = () => { ...@@ -177,6 +193,50 @@ const BudgetDetailPage: React.FC = () => {
</dl> </dl>
</Card> </Card>
{budget.isRecurring && (
<Card className="flex flex-col gap-4 p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-clay-sm bg-clay-primary/15 text-clay-primary shadow-clay-pressed">
<RefreshIcon size={20} />
</div>
<div>
<h2 className="clay-title-h3">{t("budget.autoRenewTitle")}</h2>
<p className="clay-caption">{budget.autoRenew ? t("budget.autoRenewActiveDesc") : t("budget.autoRenewPausedDesc")}</p>
</div>
</div>
<Button
variant={budget.autoRenew ? "secondary" : "primary"}
className="px-3 py-1.5 text-xs"
disabled={toggleAutoRenewMutation.isPending || budget.isArchived}
onClick={handleToggleAutoRenew}
>
{toggleAutoRenewMutation.isPending
? t("common.processing")
: budget.autoRenew
? t("budget.pauseAutoRenew")
: t("budget.resumeAutoRenew")}
</Button>
</div>
<div className="flex flex-col gap-1.5 rounded-clay-sm bg-clay-bg p-3 text-xs shadow-clay-pressed">
<div className="flex justify-between">
<span className="font-nunito text-clay-text-muted">{t("budget.rolloverPolicy")}:</span>
<span className="font-nunito font-bold text-clay-text">{t(`budget.rollover.${budget.rolloverMode.toLowerCase()}`)}</span>
</div>
{Number(budget.rolloverAmount) !== 0 && (
<div className="flex justify-between">
<span className="font-nunito text-clay-text-muted">{t("budget.rolloverAmount")}:</span>
<span className="font-baloo font-bold text-clay-primary">
{Number(budget.rolloverAmount) > 0 ? "+" : ""}
{formatCurrency(Number(budget.rolloverAmount), budget.currency)}
</span>
</div>
)}
</div>
</Card>
)}
<Card className="flex flex-col gap-3 p-5"> <Card className="flex flex-col gap-3 p-5">
<h2 className="clay-title-h3">{t("budget.management")}</h2> <h2 className="clay-title-h3">{t("budget.management")}</h2>
{!budget.isArchived ? ( {!budget.isArchived ? (
......
import React from "react"; import React, { useState } from "react";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { ChevronRightIcon } from "@/components/ui/icons"; import { ChevronRightIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
...@@ -16,12 +16,18 @@ const safeNumber = (value?: string): number => { ...@@ -16,12 +16,18 @@ const safeNumber = (value?: string): number => {
}; };
export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency, onOpenTransactions }) => { export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency, onOpenTransactions }) => {
const { t, formatCurrency, formatDate, formatNumber } = useI18n(); const { locale, t, formatCurrency, formatDate, formatNumber } = useI18n();
const [activePointIndex, setActivePointIndex] = useState<number | null>(null);
const isVi = !locale || locale.startsWith("vi");
const preset = report.period.preset;
const width = 360; const width = 360;
const height = 190; const height = 200;
const padding = { left: 54, right: 12, top: 18, bottom: 28 }; const padding = { left: 20, right: 20, top: 24, bottom: 28 };
const plotWidth = width - padding.left - padding.right; const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom; const plotHeight = height - padding.top - padding.bottom;
const points = report.series.map((bucket) => { const points = report.series.map((bucket) => {
const metric = bucket.metricsByCurrency.find((item) => item.currency === currency); const metric = bucket.metricsByCurrency.find((item) => item.currency === currency);
return { return {
...@@ -31,22 +37,155 @@ export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency, ...@@ -31,22 +37,155 @@ export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency,
net: safeNumber(metric?.netCashFlow), net: safeNumber(metric?.netCashFlow),
}; };
}); });
const chartValues = points.reduce<number[]>((values, point) => [...values, point.income, point.expense], []); const chartValues = points.reduce<number[]>((values, point) => [...values, point.income, point.expense], []);
const maxValue = Math.max(1, ...chartValues); const maxValue = Math.max(1, ...chartValues);
const xAt = (index: number) => padding.left + (points.length <= 1 ? plotWidth / 2 : (index / (points.length - 1)) * plotWidth); const xAt = (index: number) => padding.left + (points.length <= 1 ? plotWidth / 2 : (index / (points.length - 1)) * plotWidth);
const yAt = (value: number) => padding.top + plotHeight - (value / maxValue) * plotHeight; const yAt = (value: number) => padding.top + plotHeight - (value / maxValue) * plotHeight;
const toPath = (key: "income" | "expense") => points.map((point, index) => `${index === 0 ? "M" : "L"}${xAt(index).toFixed(2)},${yAt(point[key]).toFixed(2)}`).join(" ");
const toPath = (key: "income" | "expense") =>
points.map((point, index) => `${index === 0 ? "M" : "L"}${xAt(index).toFixed(2)},${yAt(point[key]).toFixed(2)}`).join(" ");
const total = report.totalsByCurrency.find((item) => item.currency === currency); const total = report.totalsByCurrency.find((item) => item.currency === currency);
const latest = points.length > 0 ? points[points.length - 1].expense : 0; const latest = points.length > 0 ? points[points.length - 1].expense : 0;
const previous = points.length > 1 ? points[points.length - 2].expense : 0; const previous = points.length > 1 ? points[points.length - 2].expense : 0;
const expenseTrend = previous > 0 ? ((latest - previous) / previous) * 100 : null; const expenseTrend = previous > 0 ? ((latest - previous) / previous) * 100 : null;
const labelIndexes = Array.from(new Set([0, Math.floor((points.length - 1) / 2), Math.max(0, points.length - 1)]));
const compactMoney = (value: number) => formatCurrency(value, currency); const compactMoney = (value: number) => formatCurrency(value, currency);
const compactAxisValue = (value: number) => formatNumber(value, { const compactAxisValue = (value: number) =>
formatNumber(value, {
notation: "compact", notation: "compact",
maximumFractionDigits: 1, maximumFractionDigits: 1,
}); });
// Weekday and month label helpers
const getWeekLabel = (dateStr: string, index: number) => {
const date = new Date(dateStr);
const day = (date.getUTCDay() + 6) % 7;
const viDays = ["Thứ 2", "Thứ 3", "Thứ 4", "Thứ 5", "Thứ 6", "Thứ 7", "CN"];
const enDays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
return isVi ? viDays[day] ?? viDays[index % 7] : enDays[day] ?? enDays[index % 7];
};
const getYearLabel = (dateStr: string, index: number) => {
const date = new Date(dateStr);
const month = date.getUTCMonth();
const viMonths = ["T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8", "T9", "T10", "T11", "T12"];
const enMonths = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return isVi ? viMonths[month] ?? viMonths[index % 12] : enMonths[month] ?? enMonths[index % 12];
};
const getAxisLabels = (): Array<{ index: number; text: string }> => {
if (preset === "WEEK" && points.length === 7) {
return points.map((p, i) => ({ index: i, text: getWeekLabel(p.from, i) }));
}
if (preset === "YEAR" && points.length === 12) {
return points.map((p, i) => ({ index: i, text: getYearLabel(p.from, i) }));
}
if (preset === "MONTH") {
const step = 5;
const indices: number[] = [];
for (let i = 0; i < points.length; i += step) {
indices.push(i);
}
if (indices[indices.length - 1] !== points.length - 1) {
indices.push(points.length - 1);
}
return indices.map((i) => ({
index: i,
text: formatDate(points[i].from, { day: "2-digit", month: "2-digit" }),
}));
}
const indices =
points.length <= 7
? points.map((_, i) => i)
: Array.from(new Set([0, Math.floor((points.length - 1) / 2), Math.max(0, points.length - 1)]));
return indices.map((i) => ({
index: i,
text: formatDate(
points[i].from,
report.granularity === "HOUR" ? { hour: "2-digit", minute: "2-digit" } : { day: "2-digit", month: "short" },
),
}));
};
const axisLabels = getAxisLabels();
const labelFontSize = preset === "YEAR" ? "8" : "9";
const amountFontSize = points.length > 7 ? "7.5" : "8.5";
// Pre-calculate label positioning and collision resolution
let lastLabeledIncomeX = -999;
let lastLabeledExpenseX = -999;
const pointLabels = points.map((point, index) => {
const x = xAt(index);
const yIncome = yAt(point.income);
const yExpense = yAt(point.expense);
let showIncome = point.income > 0;
let showExpense = point.expense > 0;
// For dense charts (> 12 points like MONTH), avoid horizontal text overlapping
if (points.length > 12) {
if (showIncome) {
if (x - lastLabeledIncomeX < 24 && index !== activePointIndex) {
showIncome = false;
} else {
lastLabeledIncomeX = x;
}
}
if (showExpense) {
if (x - lastLabeledExpenseX < 24 && index !== activePointIndex) {
showExpense = false;
} else {
lastLabeledExpenseX = x;
}
}
}
let incomeLabelY = yIncome - 8;
let expenseLabelY = yExpense - 8;
if (showIncome && showExpense) {
if (Math.abs(yIncome - yExpense) < 16) {
if (yIncome <= yExpense) {
incomeLabelY = yIncome - 8;
expenseLabelY = yExpense + 13;
} else {
expenseLabelY = yExpense - 8;
incomeLabelY = yIncome + 13;
}
}
}
// Ensure labels do not clip past top border
if (incomeLabelY < 12) incomeLabelY = yIncome + 13;
if (expenseLabelY < 12) expenseLabelY = yExpense + 13;
return {
x,
yIncome,
yExpense,
showIncome,
showExpense,
incomeLabelY,
expenseLabelY,
incomeText: compactAxisValue(point.income),
expenseText: compactAxisValue(point.expense),
};
});
const activePoint = activePointIndex !== null ? points[activePointIndex] : null;
const activeLabel =
activePointIndex !== null && activePoint
? preset === "WEEK"
? getWeekLabel(activePoint.from, activePointIndex)
: preset === "YEAR"
? getYearLabel(activePoint.from, activePointIndex)
: formatDate(activePoint.from, { day: "2-digit", month: "2-digit", year: "numeric" })
: null;
return ( return (
<Card <Card
hoverable={Boolean(onOpenTransactions)} hoverable={Boolean(onOpenTransactions)}
...@@ -55,12 +194,16 @@ export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency, ...@@ -55,12 +194,16 @@ export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency,
tabIndex={onOpenTransactions ? 0 : undefined} tabIndex={onOpenTransactions ? 0 : undefined}
aria-label={onOpenTransactions ? `${t("report.cashFlow.title")}. ${t("common.viewDetails")}` : undefined} aria-label={onOpenTransactions ? `${t("report.cashFlow.title")}. ${t("common.viewDetails")}` : undefined}
onClick={onOpenTransactions} onClick={onOpenTransactions}
onKeyDown={onOpenTransactions ? (event) => { onKeyDown={
onOpenTransactions
? (event) => {
if (event.key === "Enter" || event.key === " ") { if (event.key === "Enter" || event.key === " ") {
event.preventDefault(); event.preventDefault();
onOpenTransactions(); onOpenTransactions();
} }
} : undefined} }
: undefined
}
> >
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
...@@ -69,8 +212,14 @@ export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency, ...@@ -69,8 +212,14 @@ export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency,
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{expenseTrend !== null && ( {expenseTrend !== null && (
<span className={`rounded-full px-3 py-1 font-nunito text-xs font-bold ${expenseTrend > 0 ? "bg-clay-expense/15 text-clay-expense" : "bg-clay-income/15 text-clay-income"}`}> <span
{t("report.cashFlow.expenseTrend", { value: `${expenseTrend > 0 ? "+" : ""}${formatNumber(expenseTrend, { maximumFractionDigits: 1 })}%` })} className={`rounded-full px-3 py-1 font-nunito text-xs font-bold ${
expenseTrend > 0 ? "bg-clay-expense/15 text-clay-expense" : "bg-clay-income/15 text-clay-income"
}`}
>
{t("report.cashFlow.expenseTrend", {
value: `${expenseTrend > 0 ? "+" : ""}${formatNumber(expenseTrend, { maximumFractionDigits: 1 })}%`,
})}
</span> </span>
)} )}
{onOpenTransactions && <ChevronRightIcon size={18} aria-hidden="true" />} {onOpenTransactions && <ChevronRightIcon size={18} aria-hidden="true" />}
...@@ -78,41 +227,176 @@ export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency, ...@@ -78,41 +227,176 @@ export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency,
</div> </div>
<div className="mt-4 flex flex-wrap gap-4 text-xs font-bold"> <div className="mt-4 flex flex-wrap gap-4 text-xs font-bold">
<span className="flex items-center gap-2 text-clay-text-muted"><span className="h-2.5 w-2.5 rounded-full bg-clay-income" />{t("report.cashFlow.income")}</span> <span className="flex items-center gap-2 text-clay-text-muted">
<span className="flex items-center gap-2 text-clay-text-muted"><span className="h-2.5 w-2.5 rounded-full bg-clay-expense" />{t("report.cashFlow.expense")}</span> <span className="h-2.5 w-2.5 rounded-full bg-clay-income" />
<span className="ml-auto text-clay-text">{t("report.cashFlow.net")}: {compactMoney(safeNumber(total?.netCashFlow))}</span> {t("report.cashFlow.income")}
</span>
<span className="flex items-center gap-2 text-clay-text-muted">
<span className="h-2.5 w-2.5 rounded-full bg-clay-expense" />
{t("report.cashFlow.expense")}
</span>
</div>
{activePoint && (
<div
className="mt-2 flex items-center justify-between rounded-clay-sm bg-clay-surface px-3 py-1.5 text-xs shadow-clay-pressed animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
<span className="font-nunito font-bold text-clay-text">{activeLabel}</span>
<div className="flex items-center gap-3">
<span className="font-baloo font-bold text-clay-income">+{compactMoney(activePoint.income)}</span>
<span className="font-baloo font-bold text-clay-expense">-{compactMoney(activePoint.expense)}</span>
</div> </div>
</div>
)}
<div className="mt-3 rounded-clay bg-clay-bg p-2 shadow-clay-pressed"> <div className="mt-3 rounded-clay bg-clay-bg p-2 shadow-clay-pressed">
<p className="mb-1 px-1 font-nunito text-[11px] font-bold text-clay-text-muted"> <svg
{t("report.cashFlow.verticalAxis", { currency })} viewBox={`0 0 ${width} ${height}`}
</p> className="h-auto w-full select-none"
<svg viewBox={`0 0 ${width} ${height}`} className="h-auto w-full" role="img" aria-label={t("report.cashFlow.chartAria", { currency })}> role="img"
aria-label={t("report.cashFlow.chartAria", { currency })}
>
{/* Subtle horizontal reference grid lines */}
{[0, 0.5, 1].map((ratio) => { {[0, 0.5, 1].map((ratio) => {
const y = padding.top + plotHeight * ratio; const y = padding.top + plotHeight * ratio;
const value = maxValue * (1 - ratio);
return ( return (
<g key={ratio}> <line
<text x={padding.left - 7} y={y + 3} textAnchor="end" fill="rgb(var(--color-clay-text-muted))" fontSize="9" fontFamily="Nunito"> key={ratio}
{compactAxisValue(value)} x1={padding.left}
</text> x2={width - padding.right}
<line x1={padding.left} x2={width - padding.right} y1={y} y2={y} stroke="rgb(var(--color-clay-border))" strokeWidth="1" strokeDasharray="4 5" /> y1={y}
</g> y2={y}
stroke="rgb(var(--color-clay-border))"
strokeWidth="1"
strokeDasharray="4 5"
/>
); );
})} })}
{/* Active point vertical cursor highlight */}
{activePointIndex !== null && (
<line
x1={xAt(activePointIndex)}
x2={xAt(activePointIndex)}
y1={padding.top}
y2={padding.top + plotHeight}
stroke="rgb(var(--color-clay-primary))"
strokeWidth="1.5"
strokeDasharray="3 3"
/>
)}
{points.length > 0 && ( {points.length > 0 && (
<> <>
<path d={toPath("income")} fill="none" stroke="rgb(var(--color-clay-income))" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" /> {/* Income path */}
<path d={toPath("expense")} fill="none" stroke="rgb(var(--color-clay-expense))" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" /> <path
{points.map((point, index) => ( d={toPath("income")}
<g key={point.from}> fill="none"
<circle cx={xAt(index)} cy={yAt(point.income)} r="3.5" fill="rgb(var(--color-clay-income))" stroke="rgb(var(--color-clay-highlight))" strokeWidth="2" /> stroke="rgb(var(--color-clay-income))"
<circle cx={xAt(index)} cy={yAt(point.expense)} r="3.5" fill="rgb(var(--color-clay-expense))" stroke="rgb(var(--color-clay-highlight))" strokeWidth="2" /> strokeWidth="3.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Expense path */}
<path
d={toPath("expense")}
fill="none"
stroke="rgb(var(--color-clay-expense))"
strokeWidth="3.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Data circles */}
{points.map((point, index) => {
const isActive = activePointIndex === index;
return (
<g
key={point.from}
className="cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setActivePointIndex((prev) => (prev === index ? null : index));
}}
>
<circle
cx={xAt(index)}
cy={yAt(point.income)}
r={isActive ? 5 : 3.5}
fill="rgb(var(--color-clay-income))"
stroke="rgb(var(--color-clay-highlight))"
strokeWidth={isActive ? 2.5 : 2}
/>
<circle
cx={xAt(index)}
cy={yAt(point.expense)}
r={isActive ? 5 : 3.5}
fill="rgb(var(--color-clay-expense))"
stroke="rgb(var(--color-clay-highlight))"
strokeWidth={isActive ? 2.5 : 2}
/>
</g>
);
})}
{/* Amount labels directly on the lines */}
{pointLabels.map((item, index) => (
<g key={`lbl-${points[index].from}`} pointerEvents="none">
{item.showIncome && (
<text
x={item.x}
y={item.incomeLabelY}
textAnchor="middle"
fontSize={amountFontSize}
fontFamily="Nunito, sans-serif"
fontWeight="800"
fill="rgb(var(--color-clay-income))"
stroke="rgb(var(--color-clay-bg))"
strokeWidth="2.5"
style={{ paintOrder: "stroke fill" }}
>
{item.incomeText}
</text>
)}
{item.showExpense && (
<text
x={item.x}
y={item.expenseLabelY}
textAnchor="middle"
fontSize={amountFontSize}
fontFamily="Nunito, sans-serif"
fontWeight="800"
fill="rgb(var(--color-clay-expense))"
stroke="rgb(var(--color-clay-bg))"
strokeWidth="2.5"
style={{ paintOrder: "stroke fill" }}
>
{item.expenseText}
</text>
)}
</g> </g>
))} ))}
{labelIndexes.map((index) => (
<text key={index} x={xAt(index)} y={height - 7} textAnchor={index === 0 ? "start" : index === points.length - 1 ? "end" : "middle"} fill="rgb(var(--color-clay-text-muted))" fontSize="10" fontFamily="Nunito"> {/* X-axis labels */}
{formatDate(points[index].from, report.granularity === "HOUR" ? { hour: "2-digit", minute: "2-digit" } : { day: "2-digit", month: "short" })} {axisLabels.map(({ index, text }) => (
<text
key={`axis-${index}`}
x={xAt(index)}
y={height - 8}
textAnchor="middle"
fill={activePointIndex === index ? "rgb(var(--color-clay-primary))" : "rgb(var(--color-clay-text-muted))"}
fontSize={labelFontSize}
fontFamily="Nunito, sans-serif"
fontWeight={activePointIndex === index ? "bold" : "600"}
className="cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setActivePointIndex((prev) => (prev === index ? null : index));
}}
>
{text}
</text> </text>
))} ))}
</> </>
...@@ -123,17 +407,24 @@ export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency, ...@@ -123,17 +407,24 @@ export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency,
<div className="mt-4 grid grid-cols-3 gap-2"> <div className="mt-4 grid grid-cols-3 gap-2">
<div className="rounded-clay-sm bg-clay-income/10 p-3 text-center"> <div className="rounded-clay-sm bg-clay-income/10 p-3 text-center">
<p className="clay-caption">{t("report.cashFlow.totalIncome")}</p> <p className="clay-caption">{t("report.cashFlow.totalIncome")}</p>
<p className="mt-1 break-words font-baloo text-sm font-bold text-clay-text">{compactMoney(safeNumber(total?.income))}</p> <p className="mt-1 break-words font-baloo text-sm font-bold text-clay-text">
{compactMoney(safeNumber(total?.income))}
</p>
</div> </div>
<div className="rounded-clay-sm bg-clay-expense/10 p-3 text-center"> <div className="rounded-clay-sm bg-clay-expense/10 p-3 text-center">
<p className="clay-caption">{t("report.cashFlow.totalExpense")}</p> <p className="clay-caption">{t("report.cashFlow.totalExpense")}</p>
<p className="mt-1 break-words font-baloo text-sm font-bold text-clay-text">{compactMoney(safeNumber(total?.expense))}</p> <p className="mt-1 break-words font-baloo text-sm font-bold text-clay-text">
{compactMoney(safeNumber(total?.expense))}
</p>
</div> </div>
<div className="rounded-clay-sm bg-clay-primary/10 p-3 text-center"> <div className="rounded-clay-sm bg-clay-primary/10 p-3 text-center">
<p className="clay-caption">{t("report.cashFlow.totalNet")}</p> <p className="clay-caption">{t("report.cashFlow.totalNet")}</p>
<p className="mt-1 break-words font-baloo text-sm font-bold text-clay-text">{compactMoney(safeNumber(total?.netCashFlow))}</p> <p className="mt-1 break-words font-baloo text-sm font-bold text-clay-text">
{compactMoney(safeNumber(total?.netCashFlow))}
</p>
</div> </div>
</div> </div>
</Card> </Card>
); );
}; };
...@@ -7,7 +7,7 @@ import { SpendingCategoryCurrency } from "@/types/report"; ...@@ -7,7 +7,7 @@ import { SpendingCategoryCurrency } from "@/types/report";
interface CategoryDistributionProps { interface CategoryDistributionProps {
data: SpendingCategoryCurrency; data: SpendingCategoryCurrency;
onOpenCategories?: () => void; onOpenTransactions?: (categoryId?: string) => void;
} }
const chartColors = [ const chartColors = [
...@@ -19,7 +19,7 @@ const chartColors = [ ...@@ -19,7 +19,7 @@ const chartColors = [
"rgb(var(--color-clay-text-muted))", "rgb(var(--color-clay-text-muted))",
]; ];
export const CategoryDistribution: React.FC<CategoryDistributionProps> = ({ data, onOpenCategories }) => { export const CategoryDistribution: React.FC<CategoryDistributionProps> = ({ data, onOpenTransactions }) => {
const { t, formatCurrency, formatNumber } = useI18n(); const { t, formatCurrency, formatNumber } = useI18n();
const radius = 46; const radius = 46;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
...@@ -27,16 +27,16 @@ export const CategoryDistribution: React.FC<CategoryDistributionProps> = ({ data ...@@ -27,16 +27,16 @@ export const CategoryDistribution: React.FC<CategoryDistributionProps> = ({ data
return ( return (
<Card <Card
hoverable={Boolean(onOpenCategories)} hoverable={Boolean(onOpenTransactions)}
className="p-5 focus:outline-none focus:ring-2 focus:ring-clay-primary/30" className="p-5 focus:outline-none focus:ring-2 focus:ring-clay-primary/30"
role={onOpenCategories ? "button" : undefined} role={onOpenTransactions ? "button" : undefined}
tabIndex={onOpenCategories ? 0 : undefined} tabIndex={onOpenTransactions ? 0 : undefined}
aria-label={onOpenCategories ? `${t("report.categories.title")}. ${t("common.viewDetails")}` : undefined} aria-label={onOpenTransactions ? `${t("report.categories.title")}. ${t("common.viewDetails")}` : undefined}
onClick={onOpenCategories} onClick={onOpenTransactions ? () => onOpenTransactions() : undefined}
onKeyDown={onOpenCategories ? (event) => { onKeyDown={onOpenTransactions ? (event) => {
if (event.key === "Enter" || event.key === " ") { if (event.key === "Enter" || event.key === " ") {
event.preventDefault(); event.preventDefault();
onOpenCategories(); onOpenTransactions();
} }
} : undefined} } : undefined}
> >
...@@ -45,7 +45,7 @@ export const CategoryDistribution: React.FC<CategoryDistributionProps> = ({ data ...@@ -45,7 +45,7 @@ export const CategoryDistribution: React.FC<CategoryDistributionProps> = ({ data
<h2 className="clay-title-h2">{t("report.categories.title")}</h2> <h2 className="clay-title-h2">{t("report.categories.title")}</h2>
<p className="clay-caption">{t("report.categories.subtitle")}</p> <p className="clay-caption">{t("report.categories.subtitle")}</p>
</div> </div>
{onOpenCategories && <ChevronRightIcon size={18} aria-hidden="true" />} {onOpenTransactions && <ChevronRightIcon size={18} aria-hidden="true" />}
</div> </div>
<div className="mt-5 grid grid-cols-1 items-center gap-5 sm:grid-cols-[160px_1fr]"> <div className="mt-5 grid grid-cols-1 items-center gap-5 sm:grid-cols-[160px_1fr]">
...@@ -81,7 +81,16 @@ export const CategoryDistribution: React.FC<CategoryDistributionProps> = ({ data ...@@ -81,7 +81,16 @@ export const CategoryDistribution: React.FC<CategoryDistributionProps> = ({ data
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{data.categories.slice(0, chartColors.length).map((item, index) => ( {data.categories.slice(0, chartColors.length).map((item, index) => (
<div key={item.category.id} className="flex items-center gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed"> <div
key={item.category.id}
className={`flex items-center gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed transition-all duration-200 ${
onOpenTransactions ? "cursor-pointer hover:bg-clay-surface active:scale-[0.99]" : ""
}`}
onClick={onOpenTransactions ? (e) => {
e.stopPropagation();
onOpenTransactions(item.category.id);
} : undefined}
>
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: chartColors[index] }} /> <span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: chartColors[index] }} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-nunito text-sm font-bold text-clay-text">{getCategoryDisplayName(item.category, t)}</p> <p className="truncate font-nunito text-sm font-bold text-clay-text">{getCategoryDisplayName(item.category, t)}</p>
......
...@@ -23,7 +23,7 @@ import { OverviewMetrics } from "./components/OverviewMetrics"; ...@@ -23,7 +23,7 @@ import { OverviewMetrics } from "./components/OverviewMetrics";
import { ReportFilters } from "./components/ReportFilters"; import { ReportFilters } from "./components/ReportFilters";
import { ReportEmptyState, ReportErrorState, ReportSectionSkeleton } from "./components/ReportState"; import { ReportEmptyState, ReportErrorState, ReportSectionSkeleton } from "./components/ReportState";
import { SavingGoalsAnalytics } from "./components/SavingGoalsAnalytics"; import { SavingGoalsAnalytics } from "./components/SavingGoalsAnalytics";
import { addCalendarDays, businessDateStartIso, todayInBusinessTime } from "@/lib/business-time"; import { addCalendarDays, businessDateStartIso, instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time";
function initialCustomDates(): { from: string; to: string } { function initialCustomDates(): { from: string; to: string } {
const today = todayInBusinessTime(); const today = todayInBusinessTime();
...@@ -131,8 +131,22 @@ const ReportsPage: React.FC = () => { ...@@ -131,8 +131,22 @@ const ReportsPage: React.FC = () => {
navigate(path, { state: { fromReports: true } }); navigate(path, { state: { fromReports: true } });
}; };
const openTransactionsFromReports = (transactionTypeFilter?: TransactionType) => { const openTransactionsFromReports = (transactionTypeFilter?: TransactionType, categoryIdFilter?: string) => {
navigate("/transactions", { state: { fromReports: true, transactionTypeFilter } }); const fromDate = overview?.period.from ? instantToBusinessDate(overview.period.from) : undefined;
const toDate = overview?.period.to
? instantToBusinessDate(new Date(new Date(overview.period.to).getTime() - 1))
: undefined;
navigate("/transactions", {
state: {
fromReports: true,
transactionTypeFilter,
categoryIdFilter,
walletIdFilter: walletId || undefined,
dateFromFilter: fromDate,
dateToFilter: toDate,
},
});
}; };
const periodLabel = overview const periodLabel = overview
...@@ -219,7 +233,12 @@ const ReportsPage: React.FC = () => { ...@@ -219,7 +233,12 @@ const ReportsPage: React.FC = () => {
<div> <div>
{spendingQuery.isLoading && <ReportSectionSkeleton />} {spendingQuery.isLoading && <ReportSectionSkeleton />}
{spendingQuery.isError && <ReportErrorState onRetry={() => void spendingQuery.refetch()} />} {spendingQuery.isError && <ReportErrorState onRetry={() => void spendingQuery.refetch()} />}
{categoryData && categoryData.categories.length > 0 && <CategoryDistribution data={categoryData} onOpenCategories={canReadCategories ? () => openFromReports("/categories") : undefined} />} {categoryData && categoryData.categories.length > 0 && (
<CategoryDistribution
data={categoryData}
onOpenTransactions={canReadTransactions ? (categoryId) => openTransactionsFromReports("EXPENSE", categoryId) : undefined}
/>
)}
{spendingQuery.data && (!categoryData || categoryData.categories.length === 0) && <ReportEmptyState title={t("report.categories.empty")} hint={t("report.categories.emptyHint")} />} {spendingQuery.data && (!categoryData || categoryData.categories.length === 0) && <ReportEmptyState title={t("report.categories.empty")} hint={t("report.categories.emptyHint")} />}
</div> </div>
</div> </div>
......
...@@ -25,7 +25,16 @@ import { ...@@ -25,7 +25,16 @@ import {
import { useWallets, walletKeys } from "@/hooks/use-wallets"; import { useWallets, walletKeys } from "@/hooks/use-wallets";
import { useCategoryTree } from "@/hooks/use-categories"; import { useCategoryTree } from "@/hooks/use-categories";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
import { getTransactionId, getTransactionTypeFilter, isFromNotifications, isFromReports } from "@/lib/navigation-state"; import {
getCategoryIdFilter,
getDateFromFilter,
getDateToFilter,
getTransactionId,
getTransactionTypeFilter,
getWalletIdFilter,
isFromNotifications,
isFromReports,
} from "@/lib/navigation-state";
import { import {
CreateTransactionInput, CreateTransactionInput,
Transaction, Transaction,
...@@ -128,6 +137,10 @@ const TransactionsPage: React.FC = () => { ...@@ -128,6 +137,10 @@ const TransactionsPage: React.FC = () => {
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
const { t, intlLocale, formatDate } = useI18n(); const { t, intlLocale, formatDate } = useI18n();
const initialTransactionType = getTransactionTypeFilter(location.state); const initialTransactionType = getTransactionTypeFilter(location.state);
const initialCategoryId = getCategoryIdFilter(location.state);
const initialDateFrom = getDateFromFilter(location.state);
const initialDateTo = getDateToFilter(location.state);
const initialWalletId = getWalletIdFilter(location.state);
const notificationTransactionId = getTransactionId(location.state); const notificationTransactionId = getTransactionId(location.state);
const openedNotificationTransactionRef = useRef<string>(); const openedNotificationTransactionRef = useRef<string>();
const notifiedDetailErrorRef = useRef(false); const notifiedDetailErrorRef = useRef(false);
...@@ -135,18 +148,40 @@ const TransactionsPage: React.FC = () => { ...@@ -135,18 +148,40 @@ const TransactionsPage: React.FC = () => {
// Search & Filter state // Search & Filter state
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search.trim()); const deferredSearch = useDeferredValue(search.trim());
const [walletId, setWalletId] = useState(""); const [walletId, setWalletId] = useState(initialWalletId || "");
const [categoryId, setCategoryId] = useState(""); const [categoryId, setCategoryId] = useState(initialCategoryId || "");
const [type, setType] = useState(initialTransactionType || ""); const [type, setType] = useState(initialTransactionType || "");
const [dateFrom, setDateFrom] = useState(""); const [dateFrom, setDateFrom] = useState(initialDateFrom || "");
const [dateTo, setDateTo] = useState(""); const [dateTo, setDateTo] = useState(initialDateTo || "");
const [minAmount, setMinAmount] = useState(""); const [minAmount, setMinAmount] = useState("");
const [maxAmount, setMaxAmount] = useState(""); const [maxAmount, setMaxAmount] = useState("");
const [sort, setSort] = useState("date:desc"); const [sort, setSort] = useState("date:desc");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Filter Panel Visibility // Filter Panel Visibility
const [isFilterExpanded, setIsFilterExpanded] = useState(Boolean(initialTransactionType)); const [isFilterExpanded, setIsFilterExpanded] = useState(
Boolean(initialTransactionType || initialCategoryId || initialDateFrom || initialDateTo || initialWalletId)
);
// Sync filter state when arriving from Reports
useEffect(() => {
if (!isFromReports(location.state)) return;
const reportType = getTransactionTypeFilter(location.state);
const reportCategory = getCategoryIdFilter(location.state);
const reportDateFrom = getDateFromFilter(location.state);
const reportDateTo = getDateToFilter(location.state);
const reportWallet = getWalletIdFilter(location.state);
if (reportType !== undefined) setType(reportType);
if (reportCategory !== undefined) setCategoryId(reportCategory);
if (reportDateFrom !== undefined) setDateFrom(reportDateFrom);
if (reportDateTo !== undefined) setDateTo(reportDateTo);
if (reportWallet !== undefined) setWalletId(reportWallet);
if (reportType || reportCategory || reportDateFrom || reportDateTo || reportWallet) {
setIsFilterExpanded(true);
}
setPage(1);
}, [location.state]);
// Modals Visibility // Modals Visibility
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
......
...@@ -3,6 +3,7 @@ import { ...@@ -3,6 +3,7 @@ import {
BudgetListResponse, BudgetListResponse,
BudgetQuery, BudgetQuery,
BudgetResponse, BudgetResponse,
BudgetSeriesResponse,
CreateBudgetInput, CreateBudgetInput,
UpdateBudgetInput, UpdateBudgetInput,
} from "@/types/budget"; } from "@/types/budget";
...@@ -45,4 +46,14 @@ export const budgetService = { ...@@ -45,4 +46,14 @@ export const budgetService = {
const response = await apiClient.patch<BudgetResponse>(`/budgets/${id}/restore`); const response = await apiClient.patch<BudgetResponse>(`/budgets/${id}/restore`);
return ensureSuccess(response.data); return ensureSuccess(response.data);
}, },
async toggleAutoRenew(id: string, autoRenew: boolean): Promise<BudgetResponse> {
const response = await apiClient.patch<BudgetResponse>(`/budgets/${id}/auto-renew`, { autoRenew });
return ensureSuccess(response.data);
},
async getBudgetSeries(id: string): Promise<BudgetSeriesResponse> {
const response = await apiClient.get<BudgetSeriesResponse>(`/budgets/${id}/series`);
return ensureSuccess(response.data);
},
}; };
...@@ -4,6 +4,12 @@ export type BudgetType = "OVERALL" | "CATEGORY"; ...@@ -4,6 +4,12 @@ export type BudgetType = "OVERALL" | "CATEGORY";
export type BudgetPeriod = "CUSTOM" | "WEEKLY" | "MONTHLY" | "YEARLY"; export type BudgetPeriod = "CUSTOM" | "WEEKLY" | "MONTHLY" | "YEARLY";
export type BudgetRolloverMode =
| "RESET"
| "ROLLOVER_SURPLUS"
| "ROLLOVER_DEFICIT"
| "ROLLOVER_NET";
export type BudgetTimeStatus = "UPCOMING" | "ACTIVE" | "ENDED"; export type BudgetTimeStatus = "UPCOMING" | "ACTIVE" | "ENDED";
export type BudgetUsageStatus = "ON_TRACK" | "NEAR_LIMIT" | "EXCEEDED"; export type BudgetUsageStatus = "ON_TRACK" | "NEAR_LIMIT" | "EXCEEDED";
...@@ -48,6 +54,14 @@ export interface Budget { ...@@ -48,6 +54,14 @@ export interface Budget {
endDate: string; endDate: string;
alertThreshold: string; alertThreshold: string;
isArchived: boolean; isArchived: boolean;
isRecurring: boolean;
autoRenew: boolean;
recurrenceGroupId: string | null;
rolloverMode: BudgetRolloverMode;
rolloverAmount: string;
autoRenewUntil: string | null;
renewedAt: string | null;
parentBudgetId: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
category: BudgetCategory | null; category: BudgetCategory | null;
...@@ -62,6 +76,7 @@ export interface BudgetQuery { ...@@ -62,6 +76,7 @@ export interface BudgetQuery {
categoryId?: string; categoryId?: string;
activeAt?: string; activeAt?: string;
includeArchived: boolean; includeArchived: boolean;
isRecurring?: boolean;
sortBy: BudgetSortField; sortBy: BudgetSortField;
order: BudgetSortOrder; order: BudgetSortOrder;
page: number; page: number;
...@@ -78,6 +93,10 @@ export interface CreateBudgetInput { ...@@ -78,6 +93,10 @@ export interface CreateBudgetInput {
startDate: string; startDate: string;
endDate?: string; endDate?: string;
alertThreshold: string; alertThreshold: string;
isRecurring?: boolean;
autoRenew?: boolean;
rolloverMode?: BudgetRolloverMode;
autoRenewUntil?: string | null;
} }
export type UpdateBudgetInput = Partial<CreateBudgetInput>; export type UpdateBudgetInput = Partial<CreateBudgetInput>;
...@@ -96,3 +115,10 @@ export interface BudgetResponse { ...@@ -96,3 +115,10 @@ export interface BudgetResponse {
data: Budget; data: Budget;
errors?: unknown[] | null; errors?: unknown[] | null;
} }
export interface BudgetSeriesResponse {
success: boolean;
message?: string;
data: Budget[];
errors?: unknown[] | null;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment