Commit 333f0887 authored by ThinhNC's avatar ThinhNC

feat: add saving goals management frontend

parent ce2a86b8
......@@ -17,6 +17,7 @@ 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`.
- **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ý 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.
- **Tiêu đề trang**: Mỗi route cập nhật `document.title` theo mẫu `<Tên trang> | FinWise` thông qua component dùng chung trong router; route chưa nhận diện dùng tiêu đề mô tả sản phẩm mặc định.
- **Khoảng trống và nút điều khiển trên header hệ thống**: Zalo Mini App mặc định hiển thị `zaui-header` ở phía trên. Mọi page/layout phải chừa đủ khoảng cách phía trên (tính cả safe area khi cần) để nội dung và phần tử tương tác không bị header che khuất. Cụm điều khiển riêng của app (hiện gồm theme và ngôn ngữ) phải nằm trong `.finwise-header-controls`, đặt về bên trái vùng native `right-buttons` rộng 96px; đồng thời `zaui-header` phải dành đủ `padding-right` cho cả `right-buttons` và cụm này. Không đặt từng nút bằng offset `right` rời rạc vì có thể làm switch ngôn ngữ bị che khuất.
- **Cấu hình API**: Base URL mặc định là `http://localhost:7777/api/v1` (tương tác trực tiếp với port 7777 của Backend).
......@@ -46,7 +47,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Module quản lý giao dịch đã có frontend tại `/transactions`, kết nối Transaction API qua TanStack Query, gồm danh sách lịch sử theo ngày, xem/tạo/sửa/xóa giao dịch, lọc nâng cao, sắp xếp, phân trang và đính kèm hóa đơn.
- Module quản lý chuyển tiền đã có frontend tại `/transfers`, dùng contract `GET/POST /transfers``DELETE /transfers/:id` qua TanStack Query; gồm form chọn ví nguồn/đích, validation số dư và hai ví khác nhau, xác nhận trước khi chuyển/xóa, lịch sử có tìm kiếm/lọc/sắp xếp/phân trang và đầy đủ loading/error/empty state. Sau thao tác tạo hoặc xóa, frontend làm mới cache transfer, wallet và transaction để lấy lại số dư do Backend tính toán.
- Module quản lý ngân sách đã có frontend tại `/budgets``/budgets/:id`, kết nối Budget API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, archive/restore, tìm kiếm, lọc loại/chu kỳ/danh mục/thời điểm, sắp xếp, phân trang và cảnh báo trực quan theo tỷ lệ sử dụng; đầy đủ validation, loading/error/empty/confirmation và bản dịch Việt/Anh.
- Các module tiết kiệm, báo cáo và AI Assistant chưa được triển khai trong frontend hiện tại.
- Module mục tiêu tiết kiệm đã có frontend tại `/saving-goals``/saving-goals/:id`, kết nối Saving Goal API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, pause/resume, archive/restore, tìm kiếm, lọc trạng thái/thời hạn, sắp xếp, phân trang và quản lý đầy đủ tạo/sửa/xóa lịch sử đóng góp; có validation, loading/error/empty/confirmation và bản dịch Việt/Anh. Các module báo cáo và AI Assistant chưa được triển khai trong frontend hiện tại.
## Khi cập nhật file này
......
......@@ -31,6 +31,8 @@ import TransactionsPage from "@/pages/transactions/index";
import TransfersPage from "@/pages/transfers/index";
import BudgetsPage from "@/pages/budgets/index";
import BudgetDetailPage from "@/pages/budgets/detail";
import SavingGoalsPage from "@/pages/saving-goals/index";
import SavingGoalDetailPage from "@/pages/saving-goals/detail";
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore();
......@@ -96,6 +98,8 @@ const Layout = () => {
<Route path="/transfers" element={<AuthGuard><TransfersPage /></AuthGuard>}></Route>
<Route path="/budgets" element={<AuthGuard><BudgetsPage /></AuthGuard>}></Route>
<Route path="/budgets/:id" element={<AuthGuard><BudgetDetailPage /></AuthGuard>}></Route>
<Route path="/saving-goals" element={<AuthGuard><SavingGoalsPage /></AuthGuard>}></Route>
<Route path="/saving-goals/:id" element={<AuthGuard><SavingGoalDetailPage /></AuthGuard>}></Route>
<Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes>
</AuthInitializer>
......
......@@ -14,6 +14,12 @@ const dimensions = {
};
function CategoryGlyph({ icon }: { icon: string }) {
if (["laptop", "computer"].includes(icon)) {
return <><rect x="3" y="4" width="18" height="13" rx="2" /><path d="M2 20h20M9 17v3m6-3v3" /></>;
}
if (["plane", "travel"].includes(icon)) {
return <><path d="M22 2 9 15M22 2l-7 20-4-9-9-4 20-7Z" /></>;
}
if (["briefcase", "work"].includes(icon)) {
return <><rect x="3" y="7" width="18" height="13" rx="3" /><path d="M9 7V5h6v2M3 12h18M10 12v2h4v-2" /></>;
}
......
......@@ -21,6 +21,8 @@ const PAGE_TITLES: ReadonlyArray<{
{ matches: (pathname) => pathname === "/transfers", key: "document.transfers" },
{ matches: (pathname) => pathname === "/budgets", key: "document.budgets" },
{ matches: (pathname) => pathname.startsWith("/budgets/"), key: "document.budgetDetail" },
{ matches: (pathname) => pathname === "/saving-goals", key: "document.savingGoals" },
{ matches: (pathname) => pathname.startsWith("/saving-goals/"), key: "document.savingGoalDetail" },
{ matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" },
];
......
......@@ -32,7 +32,7 @@ export const Tabs: React.FC<TabsProps> = ({
key={tab.key}
onClick={() => onChange(tab.key)}
className={`
flex-1 text-center py-2 px-4 text-sm font-baloo font-semibold rounded-full
flex-1 text-center py-2 px-2 sm:px-4 text-xs sm:text-sm leading-tight font-baloo font-semibold rounded-full
transition-all duration-200 ease-in-out focus:outline-none z-10
${
isActive
......
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { savingGoalService } from "@/services/saving-goal.service";
import {
CreateSavingGoalInput,
SavingContributionInput,
SavingContributionQuery,
SavingGoalQuery,
UpdateSavingContributionInput,
UpdateSavingGoalInput,
} from "@/types/saving-goal";
export const savingGoalKeys = {
all: ["saving-goals"] as const,
lists: () => [...savingGoalKeys.all, "list"] as const,
list: (query: SavingGoalQuery) => [...savingGoalKeys.lists(), query] as const,
details: () => [...savingGoalKeys.all, "detail"] as const,
detail: (id: string) => [...savingGoalKeys.details(), id] as const,
contributions: (id: string) => [...savingGoalKeys.detail(id), "contributions"] as const,
contributionList: (id: string, query: SavingContributionQuery) => [...savingGoalKeys.contributions(id), query] as const,
};
export function useSavingGoals(query: SavingGoalQuery) {
return useQuery({
queryKey: savingGoalKeys.list(query),
queryFn: () => savingGoalService.getGoals(query),
placeholderData: keepPreviousData,
staleTime: 30_000,
refetchOnMount: "always",
refetchOnWindowFocus: true,
});
}
export function useSavingGoal(id: string) {
return useQuery({
queryKey: savingGoalKeys.detail(id),
queryFn: () => savingGoalService.getGoal(id),
enabled: Boolean(id),
staleTime: 30_000,
refetchOnMount: "always",
refetchOnWindowFocus: true,
});
}
export function useSavingContributions(id: string, query: SavingContributionQuery) {
return useQuery({
queryKey: savingGoalKeys.contributionList(id, query),
queryFn: () => savingGoalService.getContributions(id, query),
enabled: Boolean(id),
placeholderData: keepPreviousData,
staleTime: 30_000,
});
}
export function useCreateSavingGoal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateSavingGoalInput) => savingGoalService.createGoal(input),
onSuccess: async () => queryClient.invalidateQueries({ queryKey: savingGoalKeys.lists() }),
});
}
export function useUpdateSavingGoal(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: UpdateSavingGoalInput) => savingGoalService.updateGoal(id, input),
onSuccess: async (response) => {
queryClient.setQueryData(savingGoalKeys.detail(id), response);
await queryClient.invalidateQueries({ queryKey: savingGoalKeys.lists() });
},
});
}
function useSavingGoalAction(action: (id: string) => Promise<unknown>) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: action,
onSuccess: async () => queryClient.invalidateQueries({ queryKey: savingGoalKeys.all }),
});
}
export function useArchiveSavingGoal() {
return useSavingGoalAction(savingGoalService.archiveGoal);
}
export function useRestoreSavingGoal() {
return useSavingGoalAction(savingGoalService.restoreGoal);
}
function useContributionMutation(id: string) {
const queryClient = useQueryClient();
return async (goalResponse: { success: boolean; data: { goal: unknown } }) => {
queryClient.setQueryData(savingGoalKeys.detail(id), {
success: goalResponse.success,
data: goalResponse.data.goal,
});
await Promise.all([
queryClient.invalidateQueries({ queryKey: savingGoalKeys.contributions(id) }),
queryClient.invalidateQueries({ queryKey: savingGoalKeys.lists() }),
]);
};
}
export function useCreateSavingContribution(id: string) {
const sync = useContributionMutation(id);
return useMutation({
mutationFn: (input: SavingContributionInput) => savingGoalService.createContribution(id, input),
onSuccess: sync,
});
}
export function useUpdateSavingContribution(id: string) {
const sync = useContributionMutation(id);
return useMutation({
mutationFn: ({ contributionId, input }: { contributionId: string; input: UpdateSavingContributionInput }) =>
savingGoalService.updateContribution(id, contributionId, input),
onSuccess: sync,
});
}
export function useDeleteSavingContribution(id: string) {
const sync = useContributionMutation(id);
return useMutation({
mutationFn: (contributionId: string) => savingGoalService.deleteContribution(id, contributionId),
onSuccess: sync,
});
}
......@@ -48,7 +48,9 @@
"transactions": "Transactions",
"transfers": "Transfers",
"budgets": "Budget Management",
"budgetDetail": "Budget Details"
"budgetDetail": "Budget Details",
"savingGoals": "Saving Goals",
"savingGoalDetail": "Saving Goal Details"
},
"validation": {
"emailRequired": "Email is required",
......@@ -99,7 +101,18 @@
"budgetCategoryRequired": "Please select an expense category",
"budgetStartDateRequired": "Please select a start date",
"budgetEndDateRequired": "Please select an end date",
"budgetEndDateAfterStart": "End date must be after start date"
"budgetEndDateAfterStart": "End date must be after start date",
"goalNameRequired": "Please enter a goal name",
"goalNameMax": "Goal name cannot exceed 100 characters",
"goalAmountRequired": "Please enter the target amount",
"goalAmountInvalid": "Target amount must be greater than zero with at most 2 decimal places",
"goalDateRequired": "Please select a deadline",
"goalDateFuture": "A new deadline must be in the future",
"contributionAmountRequired": "Please enter the contribution amount",
"contributionAmountInvalid": "Contribution must be greater than zero with at most 2 decimal places",
"contributionDateRequired": "Please select the contribution time",
"contributionDateFuture": "Contribution time cannot be in the future",
"contributionNoteMax": "Note cannot exceed 500 characters"
},
"auth": {
"email": "Email",
......@@ -183,7 +196,8 @@
"styleGuide": "Explore Style Guide",
"transactions": "Manage transactions",
"transfers": "Transfer between wallets",
"budgets": "Manage spending budgets"
"budgets": "Manage spending budgets",
"savingGoals": "Manage saving goals"
},
"profile": {
"header": "Account",
......@@ -557,6 +571,168 @@
"increaseThreshold": "Increase alert threshold"
}
},
"savingGoal": {
"header": "Saving Goals",
"detailHeader": "Goal Details",
"overview": "Your saving journey",
"resultCount": "goals in the current view",
"completedCount": "{{count}} goals completed on this page",
"list": "Goal list",
"syncing": "Syncing...",
"loading": "Loading saving goals",
"loadFailed": "Could not load saving goals",
"connectionFailed": "Check your connection and try again.",
"detailLoadFailed": "Could not load goal details",
"missing": "The goal does not exist or the connection was interrupted.",
"notFound": "No matching goals found",
"empty": "No saving goals yet",
"filteredHint": "Try a different keyword, status, or deadline.",
"emptyHint": "Create your first goal and turn a financial plan into visible progress.",
"create": "Create goal",
"createShort": "New goal",
"createFirst": "Create first goal",
"createSuccess": "Saving goal created.",
"createFailed": "Could not create the goal. Check the details and try again.",
"updateSuccess": "Goal changes saved.",
"updateFailed": "Could not update the goal.",
"noChanges": "There are no changes to save.",
"target": "Target",
"saved": "Saved",
"remaining": "Remaining",
"progress": "Completion progress",
"dueDate": "Due {{date}}",
"overdue": "Overdue",
"overdueTitle": "This goal is overdue",
"overdueDescription": "Adjust the deadline or contribution plan to keep moving toward your goal.",
"completedHint": "Congratulations! You completed this goal.",
"progressHint": "{{days}} days remaining",
"deadline": "Deadline",
"daysRemaining": "Time remaining",
"days": "{{count}} days",
"contributionCount": "Contributions",
"lastContribution": "Latest contribution",
"none": "None yet",
"info": "Goal information",
"management": "Manage goal",
"edit": "Edit goal",
"pause": "Pause goal",
"resume": "Resume goal",
"archive": "Archive goal",
"restore": "Restore goal",
"archiveHint": "Archiving is a soft delete: the goal and its contribution history remain available for reports.",
"restoreHint": "The goal returns to the active list with all previous progress.",
"archiveTitle": "Archive goal?",
"archiveConfirm": "Confirm archive",
"archiveDescription": "“{{name}}” will leave the active list, but its data remains available and can be restored.",
"archiveSuccess": "Goal archived; contribution history remains available.",
"archiveFailed": "Could not archive the goal.",
"restoreTitle": "Restore goal?",
"restoreConfirm": "Confirm restore",
"restoreDescription": "“{{name}}” will be restored and completion recalculated from its contribution history.",
"restoreSuccess": "Goal restored.",
"restoreFailed": "Could not restore the goal.",
"pauseTitle": "Pause goal?",
"pauseConfirm": "Confirm pause",
"pauseDescription": "“{{name}}” will stop accepting new contributions until you resume it.",
"pauseSuccess": "Goal paused.",
"pauseFailed": "Could not pause the goal.",
"resumeTitle": "Resume goal?",
"resumeConfirm": "Confirm resume",
"resumeDescription": "“{{name}}” will become active and accept new contributions again.",
"resumeSuccess": "Goal resumed.",
"resumeFailed": "Could not resume the goal.",
"listButton": "Goal list",
"paginationLabel": "Saving goal pagination",
"status": {
"all": "All",
"active": "Active",
"paused": "Paused",
"completed": "Completed"
},
"filters": {
"searchLabel": "Search goals",
"searchPlaceholder": "Search by goal name...",
"deadline": "Filter by deadline",
"allDeadlines": "Any deadline",
"overdue": "Deadline passed",
"next30Days": "Next 30 days",
"custom": "Custom range",
"dueFrom": "Due from",
"dueTo": "Due to",
"sort": "Sort by",
"includeArchived": "Show archived goals",
"includeArchivedHint": "Archived goals are read-only until restored.",
"reset": "Clear filters"
},
"sort": {
"deadlineAsc": "Due soonest",
"deadlineDesc": "Latest deadline",
"amountDesc": "Highest target",
"amountAsc": "Lowest target",
"nameAsc": "Name A → Z",
"updatedDesc": "Recently updated"
},
"form": {
"createTitle": "Create saving goal",
"editTitle": "Edit saving goal",
"saveChanges": "Save changes",
"preview": "Goal artwork",
"previewHint": "Choose an icon and color for quick recognition.",
"name": "Goal name *",
"namePlaceholder": "For example: Buy a new laptop",
"targetAmount": "Target amount *",
"amountPlaceholder": "Enter an amount...",
"currency": "Currency *",
"currencyLocked": "Currency cannot change after the first contribution.",
"targetDate": "Deadline *",
"description": "Description",
"descriptionPlaceholder": "Why is this goal important to you?",
"icon": "Icon",
"color": "Color",
"chooseColor": "Choose color {{color}}"
},
"icons": {
"laptop": "Computer",
"plane": "Travel",
"home": "Home",
"car": "Car",
"graduation-cap": "Education",
"gift": "Gift"
},
"contribution": {
"title": "Contributions",
"hint": "This history provides the foundation for future reports and saving plans.",
"add": "Add contribution",
"addShort": "Contribute",
"addFirst": "Add first contribution",
"createTitle": "Add contribution",
"editTitle": "Edit contribution",
"amount": "Amount ({{currency}}) *",
"amountPlaceholder": "Enter an amount...",
"date": "Contribution time *",
"note": "Note",
"notePlaceholder": "For example: Set aside from this month's salary...",
"sort": "Sort contributions",
"newest": "Newest first",
"oldest": "Oldest first",
"amountDesc": "Highest amount",
"amountAsc": "Lowest amount",
"pausedHint": "Resume the goal before adding a new contribution.",
"completedHint": "This goal is complete and cannot accept new contributions.",
"loadFailed": "Could not load contribution history",
"empty": "No contributions yet",
"emptyHint": "Every small contribution brings you closer to the goal.",
"createSuccess": "Contribution added.",
"createFailed": "Could not add the contribution.",
"updateSuccess": "Contribution updated.",
"updateFailed": "Could not update the contribution.",
"deleteTitle": "Delete contribution?",
"deleteDescription": "The {{amount}} contribution will be permanently deleted and goal progress recalculated. This cannot be undone.",
"deleteSuccess": "Contribution deleted and progress updated.",
"deleteFailed": "Could not delete the contribution.",
"paginationLabel": "Contribution history pagination"
}
},
"styleGuide": {
"header": "Style Guide & Design System",
"title": "Claymorphism Theme System",
......
......@@ -48,7 +48,9 @@
"transactions": "Quản lý giao dịch",
"transfers": "Quản lý chuyển tiền",
"budgets": "Quản lý ngân sách",
"budgetDetail": "Chi tiết ngân sách"
"budgetDetail": "Chi tiết ngân sách",
"savingGoals": "Mục tiêu tiết kiệm",
"savingGoalDetail": "Chi tiết mục tiêu tiết kiệm"
},
"validation": {
"emailRequired": "Email không được để trống",
......@@ -99,7 +101,18 @@
"budgetCategoryRequired": "Vui lòng chọn danh mục chi tiêu",
"budgetStartDateRequired": "Vui lòng chọn ngày bắt đầu",
"budgetEndDateRequired": "Vui lòng chọn ngày kết thúc",
"budgetEndDateAfterStart": "Ngày kết thúc phải sau ngày bắt đầu"
"budgetEndDateAfterStart": "Ngày kết thúc phải sau ngày bắt đầu",
"goalNameRequired": "Vui lòng nhập tên mục tiêu",
"goalNameMax": "Tên mục tiêu tối đa 100 ký tự",
"goalAmountRequired": "Vui lòng nhập số tiền mục tiêu",
"goalAmountInvalid": "Số tiền mục tiêu phải lớn hơn 0 và có tối đa 2 chữ số thập phân",
"goalDateRequired": "Vui lòng chọn thời hạn",
"goalDateFuture": "Thời hạn mới phải nằm trong tương lai",
"contributionAmountRequired": "Vui lòng nhập số tiền đóng góp",
"contributionAmountInvalid": "Số tiền đóng góp phải lớn hơn 0 và có tối đa 2 chữ số thập phân",
"contributionDateRequired": "Vui lòng chọn thời gian đóng góp",
"contributionDateFuture": "Thời gian đóng góp không được nằm trong tương lai",
"contributionNoteMax": "Ghi chú tối đa 500 ký tự"
},
"auth": {
"email": "Email",
......@@ -183,7 +196,8 @@
"styleGuide": "Khám phá Style Guide",
"transactions": "Quản lý giao dịch thu chi",
"transfers": "Chuyển tiền giữa các ví",
"budgets": "Quản lý ngân sách chi tiêu"
"budgets": "Quản lý ngân sách chi tiêu",
"savingGoals": "Quản lý mục tiêu tiết kiệm"
},
"profile": {
"header": "Tài Khoản",
......@@ -566,6 +580,168 @@
"increaseThreshold": "Tăng ngưỡng cảnh báo"
}
},
"savingGoal": {
"header": "Mục tiêu tiết kiệm",
"detailHeader": "Chi tiết mục tiêu",
"overview": "Hành trình tiết kiệm",
"resultCount": "mục tiêu theo bộ lọc hiện tại",
"completedCount": "{{count}} mục tiêu hoàn thành trên trang",
"list": "Danh sách mục tiêu",
"syncing": "Đang đồng bộ...",
"loading": "Đang tải mục tiêu tiết kiệm",
"loadFailed": "Không thể tải mục tiêu tiết kiệm",
"connectionFailed": "Kiểm tra kết nối và thử lại.",
"detailLoadFailed": "Không thể tải chi tiết mục tiêu",
"missing": "Mục tiêu không tồn tại hoặc kết nối bị gián đoạn.",
"notFound": "Không tìm thấy mục tiêu phù hợp",
"empty": "Chưa có mục tiêu tiết kiệm",
"filteredHint": "Hãy thử từ khóa, trạng thái hoặc thời hạn khác.",
"emptyHint": "Tạo mục tiêu đầu tiên để biến kế hoạch tài chính thành tiến độ rõ ràng.",
"create": "Tạo mục tiêu",
"createShort": "Tạo mới",
"createFirst": "Tạo mục tiêu đầu tiên",
"createSuccess": "Đã tạo mục tiêu tiết kiệm.",
"createFailed": "Không thể tạo mục tiêu. Vui lòng kiểm tra thông tin và thử lại.",
"updateSuccess": "Đã lưu thay đổi mục tiêu.",
"updateFailed": "Không thể cập nhật mục tiêu.",
"noChanges": "Không có thay đổi cần lưu.",
"target": "Mục tiêu",
"saved": "Đã tiết kiệm",
"remaining": "Còn lại",
"progress": "Tiến độ hoàn thành",
"dueDate": "Thời hạn {{date}}",
"overdue": "Đã quá hạn",
"overdueTitle": "Mục tiêu đã quá thời hạn",
"overdueDescription": "Hãy điều chỉnh thời hạn hoặc kế hoạch đóng góp để tiếp tục tiến về mục tiêu.",
"completedHint": "Chúc mừng! Bạn đã hoàn thành mục tiêu này.",
"progressHint": "Còn {{days}} ngày để hoàn thành",
"deadline": "Thời hạn",
"daysRemaining": "Thời gian còn lại",
"days": "{{count}} ngày",
"contributionCount": "Số khoản đóng góp",
"lastContribution": "Đóng góp gần nhất",
"none": "Chưa có",
"info": "Thông tin mục tiêu",
"management": "Quản lý mục tiêu",
"edit": "Chỉnh sửa mục tiêu",
"pause": "Tạm dừng mục tiêu",
"resume": "Tiếp tục mục tiêu",
"archive": "Lưu trữ mục tiêu",
"restore": "Khôi phục mục tiêu",
"archiveHint": "Lưu trữ là xóa mềm: mục tiêu và toàn bộ lịch sử đóng góp vẫn được giữ cho báo cáo.",
"restoreHint": "Mục tiêu sẽ trở lại danh sách hoạt động với toàn bộ tiến độ trước đó.",
"archiveTitle": "Lưu trữ mục tiêu?",
"archiveConfirm": "Xác nhận lưu trữ",
"archiveDescription": "“{{name}}” sẽ bị xóa khỏi danh sách hoạt động nhưng dữ liệu vẫn được bảo toàn và có thể khôi phục.",
"archiveSuccess": "Đã lưu trữ mục tiêu; lịch sử đóng góp vẫn được bảo toàn.",
"archiveFailed": "Không thể lưu trữ mục tiêu.",
"restoreTitle": "Khôi phục mục tiêu?",
"restoreConfirm": "Xác nhận khôi phục",
"restoreDescription": "“{{name}}” sẽ được khôi phục và trạng thái hoàn thành được tính lại từ lịch sử đóng góp.",
"restoreSuccess": "Đã khôi phục mục tiêu.",
"restoreFailed": "Không thể khôi phục mục tiêu.",
"pauseTitle": "Tạm dừng mục tiêu?",
"pauseConfirm": "Xác nhận tạm dừng",
"pauseDescription": "“{{name}}” sẽ tạm ngừng nhận khoản đóng góp mới cho đến khi bạn tiếp tục.",
"pauseSuccess": "Đã tạm dừng mục tiêu.",
"pauseFailed": "Không thể tạm dừng mục tiêu.",
"resumeTitle": "Tiếp tục mục tiêu?",
"resumeConfirm": "Xác nhận tiếp tục",
"resumeDescription": "“{{name}}” sẽ hoạt động trở lại và có thể nhận khoản đóng góp mới.",
"resumeSuccess": "Mục tiêu đã hoạt động trở lại.",
"resumeFailed": "Không thể tiếp tục mục tiêu.",
"listButton": "Danh sách mục tiêu",
"paginationLabel": "Phân trang mục tiêu tiết kiệm",
"status": {
"all": "Tất cả",
"active": "Đang thực hiện",
"paused": "Tạm dừng",
"completed": "Hoàn thành"
},
"filters": {
"searchLabel": "Tìm kiếm mục tiêu",
"searchPlaceholder": "Tìm theo tên mục tiêu...",
"deadline": "Lọc theo thời hạn",
"allDeadlines": "Mọi thời hạn",
"overdue": "Thời hạn đã qua",
"next30Days": "Trong 30 ngày tới",
"custom": "Khoảng tùy chỉnh",
"dueFrom": "Hạn từ ngày",
"dueTo": "Hạn đến ngày",
"sort": "Sắp xếp",
"includeArchived": "Hiển thị mục tiêu đã lưu trữ",
"includeArchivedHint": "Mục tiêu lưu trữ chỉ đọc cho đến khi được khôi phục.",
"reset": "Xóa bộ lọc"
},
"sort": {
"deadlineAsc": "Sắp đến hạn",
"deadlineDesc": "Thời hạn xa nhất",
"amountDesc": "Mục tiêu cao nhất",
"amountAsc": "Mục tiêu thấp nhất",
"nameAsc": "Tên A → Z",
"updatedDesc": "Mới cập nhật"
},
"form": {
"createTitle": "Tạo mục tiêu tiết kiệm",
"editTitle": "Chỉnh sửa mục tiêu",
"saveChanges": "Lưu thay đổi",
"preview": "Hình ảnh mục tiêu",
"previewHint": "Chọn biểu tượng và màu giúp dễ nhận biết.",
"name": "Tên mục tiêu *",
"namePlaceholder": "Ví dụ: Mua laptop mới",
"targetAmount": "Số tiền mục tiêu *",
"amountPlaceholder": "Nhập số tiền...",
"currency": "Tiền tệ *",
"currencyLocked": "Không thể đổi tiền tệ sau khi đã có khoản đóng góp.",
"targetDate": "Thời hạn *",
"description": "Mô tả",
"descriptionPlaceholder": "Mục tiêu này quan trọng với bạn như thế nào?",
"icon": "Biểu tượng",
"color": "Màu sắc",
"chooseColor": "Chọn màu {{color}}"
},
"icons": {
"laptop": "Máy tính",
"plane": "Du lịch",
"home": "Nhà ở",
"car": "Xe cộ",
"graduation-cap": "Học tập",
"gift": "Quà tặng"
},
"contribution": {
"title": "Các khoản đóng góp",
"hint": "Lịch sử này là nền tảng cho báo cáo và kế hoạch tiết kiệm sau này.",
"add": "Thêm đóng góp",
"addShort": "Đóng góp",
"addFirst": "Thêm khoản đầu tiên",
"createTitle": "Thêm khoản đóng góp",
"editTitle": "Chỉnh sửa khoản đóng góp",
"amount": "Số tiền ({{currency}}) *",
"amountPlaceholder": "Nhập số tiền...",
"date": "Thời gian đóng góp *",
"note": "Ghi chú",
"notePlaceholder": "Ví dụ: Trích từ lương tháng này...",
"sort": "Sắp xếp đóng góp",
"newest": "Mới nhất",
"oldest": "Cũ nhất",
"amountDesc": "Số tiền cao nhất",
"amountAsc": "Số tiền thấp nhất",
"pausedHint": "Tiếp tục mục tiêu trước khi thêm khoản đóng góp mới.",
"completedHint": "Mục tiêu đã hoàn thành nên không nhận thêm khoản đóng góp mới.",
"loadFailed": "Không thể tải lịch sử đóng góp",
"empty": "Chưa có khoản đóng góp",
"emptyHint": "Mỗi khoản nhỏ đều đưa bạn đến gần mục tiêu hơn.",
"createSuccess": "Đã thêm khoản đóng góp.",
"createFailed": "Không thể thêm khoản đóng góp.",
"updateSuccess": "Đã cập nhật khoản đóng góp.",
"updateFailed": "Không thể cập nhật khoản đóng góp.",
"deleteTitle": "Xóa khoản đóng góp?",
"deleteDescription": "Khoản {{amount}} sẽ bị xóa vĩnh viễn và tiến độ mục tiêu được tính lại. Thao tác này không thể hoàn tác.",
"deleteSuccess": "Đã xóa khoản đóng góp và cập nhật tiến độ.",
"deleteFailed": "Không thể xóa khoản đóng góp.",
"paginationLabel": "Phân trang lịch sử đóng góp"
}
},
"styleGuide": {
"header": "Style Guide & Design System",
"title": "Hệ thống giao diện Claymorphism",
......
export const positiveAmountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
return {
group: parts.find((part) => part.type === "group")?.value || ",",
decimal: parts.find((part) => part.type === "decimal")?.value || ".",
};
}
export function formatMoneyInput(value: string, locale: string): string {
if (!value) return value;
const [integerPart, decimalPart] = value.split(".");
const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
}
export function parseMoneyInput(value: string, locale: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) return "";
const { group, decimal } = getNumberSeparators(locale);
let integerDisplay = trimmedValue;
let decimalDisplay: string | undefined;
if (trimmedValue.includes(decimal)) {
[integerDisplay, decimalDisplay] = trimmedValue.split(group).join("").split(decimal, 2);
} else if (/^\d+[.,]\d{0,2}$/.test(trimmedValue)) {
const separatorIndex = Math.max(trimmedValue.lastIndexOf("."), trimmedValue.lastIndexOf(","));
integerDisplay = trimmedValue.slice(0, separatorIndex);
decimalDisplay = trimmedValue.slice(separatorIndex + 1);
} else {
integerDisplay = trimmedValue.split(group).join("");
}
const integerDigits = integerDisplay.replace(/\D/g, "").slice(0, 16);
const normalizedInteger = integerDigits.replace(/^0+(?=\d)/, "") || "0";
const decimalDigits = decimalDisplay?.replace(/\D/g, "").slice(0, 2);
return `${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
}
export function toLocalDate(value?: string): string {
const date = value ? new Date(value) : new Date();
if (Number.isNaN(date.getTime())) return "";
return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
}
export function toLocalDateTime(value?: string): string {
const date = value ? new Date(value) : new Date();
if (Number.isNaN(date.getTime())) return "";
return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
}
......@@ -81,6 +81,13 @@ function HomePage() {
>
{t("home.budgets")}
</Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/saving-goals")}
>
{t("home.savingGoals")}
</Button>
<Button
variant="secondary"
fullWidth
......
import React, { useEffect, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { TranslationFunction, useI18n } from "@/i18n";
import { formatMoneyInput, parseMoneyInput, positiveAmountPattern, toLocalDateTime } from "@/lib/money-input";
import { SavingContribution, SavingContributionInput } from "@/types/saving-goal";
const createSchema = (t: TranslationFunction) => z.object({
amount: z.string().trim().min(1, t("validation.contributionAmountRequired"))
.regex(positiveAmountPattern, t("validation.contributionAmountInvalid"))
.refine((value) => Number(value) > 0, t("validation.contributionAmountInvalid")),
contributedAt: z.string().min(1, t("validation.contributionDateRequired"))
.refine((value) => new Date(value).getTime() <= Date.now(), t("validation.contributionDateFuture")),
note: z.string().max(500, t("validation.contributionNoteMax")),
});
type ContributionFormValues = z.infer<ReturnType<typeof createSchema>>;
interface ContributionFormModalProps {
isOpen: boolean;
contribution?: SavingContribution;
currency: string;
isSubmitting: boolean;
onClose: () => void;
onSubmit: (input: SavingContributionInput) => void;
}
function getDefaultValues(contribution?: SavingContribution): ContributionFormValues {
return {
amount: contribution?.amount || "",
contributedAt: toLocalDateTime(contribution?.contributedAt),
note: contribution?.note || "",
};
}
export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
isOpen,
contribution,
currency,
isSubmitting,
onClose,
onSubmit,
}) => {
const { intlLocale, t } = useI18n();
const schema = useMemo(() => createSchema(t), [t]);
const defaultValues = useMemo(() => getDefaultValues(contribution), [contribution]);
const formId = contribution ? `edit-contribution-${contribution.id}` : "create-contribution";
const { control, register, handleSubmit, reset, formState: { errors } } = useForm<ContributionFormValues>({
resolver: zodResolver(schema),
defaultValues,
});
useEffect(() => {
if (isOpen) reset(defaultValues);
}, [defaultValues, isOpen, reset]);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={contribution ? t("savingGoal.contribution.editTitle") : t("savingGoal.contribution.createTitle")}
footer={(
<>
<Button type="button" variant="ghost" className="px-4 text-sm" disabled={isSubmitting} onClick={onClose}>{t("common.cancel")}</Button>
<Button type="submit" form={formId} className="px-4 text-sm" disabled={isSubmitting}>
{isSubmitting ? t("common.saving") : contribution ? t("common.save") : t("savingGoal.contribution.add")}
</Button>
</>
)}
>
<form
id={formId}
className="flex flex-col gap-4"
onSubmit={handleSubmit((values) => onSubmit({
amount: values.amount,
contributedAt: new Date(values.contributedAt).toISOString(),
note: values.note.trim() || null,
}))}
noValidate
>
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
label={t("savingGoal.contribution.amount", { currency })}
inputMode="decimal"
value={formatMoneyInput(field.value, intlLocale)}
placeholder={t("savingGoal.contribution.amountPlaceholder")}
disabled={isSubmitting}
error={errors.amount?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(parseMoneyInput(event.target.value, intlLocale))}
/>
)}
/>
<Input
type="datetime-local"
max={toLocalDateTime()}
label={t("savingGoal.contribution.date")}
disabled={isSubmitting}
error={errors.contributedAt?.message}
{...register("contributedAt")}
/>
<div className="flex flex-col gap-2">
<label htmlFor={`${formId}-note`} className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("savingGoal.contribution.note")}</label>
<textarea
id={`${formId}-note`}
rows={3}
maxLength={500}
disabled={isSubmitting}
placeholder={t("savingGoal.contribution.notePlaceholder")}
className="w-full resize-none rounded-clay-sm border border-transparent bg-clay-bg px-4 py-3 font-nunito text-base text-clay-text shadow-clay-pressed transition-all duration-200 ease-in-out placeholder-clay-text-muted/65 focus:border-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
{...register("note")}
/>
{errors.note?.message && <span className="px-1 font-nunito text-xs text-clay-expense">{errors.note.message}</span>}
</div>
</form>
</Modal>
);
};
import React from "react";
import { CategoryArtwork } from "@/components/shared/CategoryArtwork";
import { Badge, BadgeType } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { ProgressBar } from "@/components/ui/ProgressBar";
import { useI18n } from "@/i18n";
import { SavingGoal, SavingGoalStatus } from "@/types/saving-goal";
interface SavingGoalCardProps {
goal: SavingGoal;
onClick: () => void;
}
const statusStyle: Record<SavingGoalStatus, BadgeType> = {
ACTIVE: "primary",
PAUSED: "warning",
COMPLETED: "income",
};
export const SavingGoalCard: React.FC<SavingGoalCardProps> = ({ goal, onClick }) => {
const { formatCurrency, formatDate, formatNumber, t } = useI18n();
const progress = Number(goal.progress.progressPercentage);
const progressType: BadgeType = goal.status === "COMPLETED"
? "income"
: goal.progress.isOverdue
? "expense"
: goal.status === "PAUSED" ? "warning" : "primary";
return (
<Card
hoverable
role="button"
tabIndex={0}
className={`p-5 ${goal.isArchived ? "opacity-70" : ""}`}
onClick={onClick}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
}}
>
<div className="flex items-start gap-3">
<CategoryArtwork icon={goal.icon || "gift"} color={goal.color || "#8B7CF6"} archived={goal.isArchived} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate font-baloo text-lg font-bold text-clay-text">{goal.name}</h3>
{goal.isArchived && <Badge type="info">{t("common.archived")}</Badge>}
</div>
<p className="font-nunito text-xs font-semibold text-clay-text-muted">
{t("savingGoal.dueDate", { date: formatDate(goal.targetDate, { day: "2-digit", month: "2-digit", year: "numeric" }) })}
</p>
{goal.progress.isOverdue && <p className="mt-1 font-nunito text-xs font-bold text-clay-expense">{t("savingGoal.overdue")}</p>}
</div>
<Badge type={statusStyle[goal.status]}>{t(`savingGoal.status.${goal.status.toLowerCase()}`)}</Badge>
</div>
<div className="mt-5 grid grid-cols-3 gap-2">
<div className="rounded-clay-sm bg-clay-bg p-2.5 shadow-clay-pressed">
<p className="font-nunito text-[11px] font-bold text-clay-text-muted">{t("savingGoal.target")}</p>
<p className="mt-1 truncate font-baloo text-sm font-bold text-clay-text">{formatCurrency(Number(goal.targetAmount), goal.currency)}</p>
</div>
<div className="rounded-clay-sm bg-clay-income/10 p-2.5 shadow-clay-pressed">
<p className="font-nunito text-[11px] font-bold text-clay-text-muted">{t("savingGoal.saved")}</p>
<p className="mt-1 truncate font-baloo text-sm font-bold text-clay-income">{formatCurrency(Number(goal.progress.savedAmount), goal.currency)}</p>
</div>
<div className="rounded-clay-sm bg-clay-warning/10 p-2.5 shadow-clay-pressed">
<p className="font-nunito text-[11px] font-bold text-clay-text-muted">{t("savingGoal.remaining")}</p>
<p className="mt-1 truncate font-baloo text-sm font-bold text-clay-warning">{formatCurrency(Number(goal.progress.remainingAmount), goal.currency)}</p>
</div>
</div>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="font-nunito text-xs font-bold text-clay-text-muted">{t("savingGoal.progress")}</span>
<span className={`font-baloo text-base font-bold ${goal.progress.isOverdue ? "text-clay-expense" : goal.status === "COMPLETED" ? "text-clay-income" : "text-clay-text"}`}>
{formatNumber(progress, { maximumFractionDigits: 1 })}%
</span>
</div>
<ProgressBar value={progress} type={progressType} />
</div>
</Card>
);
};
import React, { useEffect, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { CategoryArtwork } from "@/components/shared/CategoryArtwork";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { TranslationFunction, useI18n } from "@/i18n";
import { formatMoneyInput, parseMoneyInput, positiveAmountPattern, toLocalDate } from "@/lib/money-input";
import { CreateSavingGoalInput, SavingGoal } from "@/types/saving-goal";
const GOAL_COLORS = ["#16A34A", "#0D9488", "#3B82F6", "#6366F1", "#A855F7", "#EC4899", "#EF4444", "#F97316", "#EAB308", "#64748B"];
const GOAL_ICONS = ["laptop", "plane", "home", "car", "graduation-cap", "gift"];
function tomorrow(): string {
const date = new Date();
date.setDate(date.getDate() + 1);
return toLocalDate(date.toISOString());
}
const createSchema = (t: TranslationFunction, currentDate?: string) => z.object({
name: z.string().trim().min(1, t("validation.goalNameRequired")).max(100, t("validation.goalNameMax")),
targetAmount: z.string().trim().min(1, t("validation.goalAmountRequired"))
.regex(positiveAmountPattern, t("validation.goalAmountInvalid"))
.refine((value) => Number(value) > 0, t("validation.goalAmountInvalid")),
currency: z.string().length(3, t("validation.currencyLength")).regex(/^[A-Za-z]{3}$/, t("validation.currencyLetters")),
targetDate: z.string().min(1, t("validation.goalDateRequired"))
.refine((value) => value === currentDate || new Date(`${value}T23:59:59`).getTime() > Date.now(), t("validation.goalDateFuture")),
description: z.string().max(500, t("validation.descriptionMax")),
icon: z.string().min(1).max(100),
color: z.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, t("validation.colorInvalid")),
});
type SavingGoalFormValues = z.infer<ReturnType<typeof createSchema>>;
interface SavingGoalFormModalProps {
isOpen: boolean;
goal?: SavingGoal;
isSubmitting: boolean;
onClose: () => void;
onSubmit: (input: CreateSavingGoalInput) => void;
}
function getDefaultValues(goal?: SavingGoal): SavingGoalFormValues {
return {
name: goal?.name || "",
targetAmount: goal?.targetAmount || "",
currency: goal?.currency || "VND",
targetDate: goal ? toLocalDate(goal.targetDate) : tomorrow(),
description: goal?.description || "",
icon: goal?.icon || "gift",
color: goal?.color || "#8B7CF6",
};
}
export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
isOpen,
goal,
isSubmitting,
onClose,
onSubmit,
}) => {
const { intlLocale, t } = useI18n();
const currentDate = goal ? toLocalDate(goal.targetDate) : undefined;
const schema = useMemo(() => createSchema(t, currentDate), [currentDate, t]);
const defaultValues = useMemo(() => getDefaultValues(goal), [goal]);
const formId = goal ? `edit-saving-goal-${goal.id}` : "create-saving-goal";
const {
control,
register,
handleSubmit,
reset,
setValue,
watch,
formState: { errors },
} = useForm<SavingGoalFormValues>({ resolver: zodResolver(schema), defaultValues });
useEffect(() => {
if (isOpen) reset(defaultValues);
}, [defaultValues, isOpen, reset]);
const selectedIcon = watch("icon");
const selectedColor = watch("color");
const currencyLocked = Boolean(goal && goal.progress.contributionCount > 0);
const submitForm = (values: SavingGoalFormValues) => {
onSubmit({
name: values.name.trim(),
targetAmount: values.targetAmount,
currency: values.currency.toUpperCase(),
targetDate: new Date(`${values.targetDate}T23:59:59`).toISOString(),
description: values.description.trim() || null,
icon: values.icon,
color: values.color,
});
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={goal ? t("savingGoal.form.editTitle") : t("savingGoal.form.createTitle")}
footer={(
<>
<Button type="button" variant="ghost" className="px-4 text-sm" disabled={isSubmitting} onClick={onClose}>{t("common.cancel")}</Button>
<Button type="submit" form={formId} className="px-4 text-sm" disabled={isSubmitting}>
{isSubmitting ? t("common.saving") : goal ? t("savingGoal.form.saveChanges") : t("savingGoal.create")}
</Button>
</>
)}
>
<form id={formId} className="flex flex-col gap-4" onSubmit={handleSubmit(submitForm)} noValidate>
<div className="flex items-center gap-3 rounded-clay bg-clay-bg p-3 shadow-clay-pressed">
<CategoryArtwork icon={selectedIcon} color={selectedColor} />
<div>
<p className="font-baloo font-bold text-clay-text">{t("savingGoal.form.preview")}</p>
<p className="clay-caption">{t("savingGoal.form.previewHint")}</p>
</div>
</div>
<Input label={t("savingGoal.form.name")} placeholder={t("savingGoal.form.namePlaceholder")} maxLength={100} disabled={isSubmitting} error={errors.name?.message} {...register("name")} />
<div className="grid grid-cols-[minmax(0,1fr)_105px] gap-3">
<Controller
name="targetAmount"
control={control}
render={({ field }) => (
<Input
label={t("savingGoal.form.targetAmount")}
inputMode="decimal"
placeholder={t("savingGoal.form.amountPlaceholder")}
value={formatMoneyInput(field.value, intlLocale)}
disabled={isSubmitting}
error={errors.targetAmount?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(parseMoneyInput(event.target.value, intlLocale))}
/>
)}
/>
<Controller
name="currency"
control={control}
render={({ field }) => (
<Input
label={t("savingGoal.form.currency")}
value={field.value}
maxLength={3}
autoCapitalize="characters"
disabled={isSubmitting || currencyLocked}
error={errors.currency?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(event.target.value.replace(/[^A-Za-z]/g, "").toUpperCase())}
/>
)}
/>
</div>
{currencyLocked && <p className="-mt-2 px-1 clay-caption">{t("savingGoal.form.currencyLocked")}</p>}
<Input type="date" min={goal ? undefined : tomorrow()} label={t("savingGoal.form.targetDate")} disabled={isSubmitting} error={errors.targetDate?.message} {...register("targetDate")} />
<div className="flex flex-col gap-2">
<label htmlFor={`${formId}-description`} className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("savingGoal.form.description")}</label>
<textarea
id={`${formId}-description`}
rows={3}
maxLength={500}
disabled={isSubmitting}
placeholder={t("savingGoal.form.descriptionPlaceholder")}
className="w-full resize-none rounded-clay-sm border border-transparent bg-clay-bg px-4 py-3 font-nunito text-base text-clay-text shadow-clay-pressed transition-all duration-200 ease-in-out placeholder-clay-text-muted/65 focus:border-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
{...register("description")}
/>
{errors.description?.message && <span className="px-1 font-nunito text-xs text-clay-expense">{errors.description.message}</span>}
</div>
<fieldset className="flex flex-col gap-2">
<legend className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("savingGoal.form.icon")}</legend>
<div className="grid grid-cols-6 gap-2">
{GOAL_ICONS.map((icon) => (
<button
key={icon}
type="button"
aria-label={t(`savingGoal.icons.${icon}`)}
aria-pressed={selectedIcon === icon}
disabled={isSubmitting}
className={`flex justify-center rounded-clay-sm p-1.5 transition-all duration-200 ease-in-out ${selectedIcon === icon ? "bg-clay-primary/20 shadow-clay-pressed" : "bg-clay-bg shadow-clay-raised"}`}
onClick={() => setValue("icon", icon, { shouldValidate: true })}
>
<CategoryArtwork icon={icon} color={selectedColor} size="sm" />
</button>
))}
</div>
</fieldset>
<fieldset className="flex flex-col gap-2">
<legend className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("savingGoal.form.color")}</legend>
<div className="flex flex-wrap gap-3 px-1">
{GOAL_COLORS.map((color) => (
<button
key={color}
type="button"
aria-label={t("savingGoal.form.chooseColor", { color })}
aria-pressed={selectedColor === color}
disabled={isSubmitting}
className={`h-9 w-9 rounded-full border-2 transition-all duration-200 ease-in-out ${selectedColor === color ? "scale-110 border-clay-text shadow-clay-raised" : "border-clay-highlight/70"}`}
style={{ backgroundColor: color }}
onClick={() => setValue("color", color, { shouldValidate: true })}
/>
))}
</div>
</fieldset>
</form>
</Modal>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
export const SavingGoalSkeleton: React.FC = () => (
<Card className="animate-pulse p-5" aria-hidden="true">
<div className="flex items-start gap-3">
<div className="h-12 w-12 shrink-0 rounded-clay bg-clay-text-muted/15 shadow-clay-pressed" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-5 w-2/3 rounded-full bg-clay-text-muted/15" />
<div className="h-3 w-1/2 rounded-full bg-clay-text-muted/10" />
</div>
<div className="h-6 w-16 rounded-full bg-clay-income/15" />
</div>
<div className="mt-5 grid grid-cols-3 gap-2">
{Array.from({ length: 3 }, (_, index) => (
<div key={index} className="h-14 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
))}
</div>
<div className="mt-4 h-4 rounded-full bg-clay-bg shadow-clay-pressed" />
</Card>
);
import React, { useEffect, useMemo, useState } from "react";
import { Header, Page, useNavigate, useParams, useSnackbar } from "zmp-ui";
import { CategoryArtwork } from "@/components/shared/CategoryArtwork";
import { Badge, BadgeType } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Modal } from "@/components/ui/Modal";
import { ProgressBar } from "@/components/ui/ProgressBar";
import { Select } from "@/components/ui/Select";
import { IconGradients, PlusIcon } from "@/components/ui/icons";
import {
useArchiveSavingGoal,
useCreateSavingContribution,
useDeleteSavingContribution,
useRestoreSavingGoal,
useSavingContributions,
useSavingGoal,
useUpdateSavingContribution,
useUpdateSavingGoal,
} from "@/hooks/use-saving-goals";
import { useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
import { toLocalDate } from "@/lib/money-input";
import {
CreateSavingGoalInput,
SavingContribution,
SavingContributionInput,
SavingContributionQuery,
SavingGoalStatus,
UpdateSavingGoalInput,
} from "@/types/saving-goal";
import { ContributionFormModal } from "./components/ContributionFormModal";
import { SavingGoalFormModal } from "./components/SavingGoalFormModal";
import { SavingGoalSkeleton } from "./components/SavingGoalSkeleton";
const CONTRIBUTION_PAGE_SIZE = 6;
type GoalAction = "archive" | "restore" | "pause" | "resume";
const statusStyle: Record<SavingGoalStatus, BadgeType> = {
ACTIVE: "primary",
PAUSED: "warning",
COMPLETED: "income",
};
const SavingGoalDetailPage: React.FC = () => {
const params = useParams<{ id: string }>();
const goalId = params.id || "";
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const { formatCurrency, formatDate, formatNumber, t } = useI18n();
const [isEditOpen, setIsEditOpen] = useState(false);
const [isContributionOpen, setIsContributionOpen] = useState(false);
const [editingContribution, setEditingContribution] = useState<SavingContribution | undefined>();
const [deletingContribution, setDeletingContribution] = useState<SavingContribution | undefined>();
const [confirmAction, setConfirmAction] = useState<GoalAction | null>(null);
const [contributionPage, setContributionPage] = useState(1);
const [contributionSort, setContributionSort] = useState("contributedAt:desc");
const [contributionSortBy, contributionOrder] = contributionSort.split(":") as [SavingContributionQuery["sortBy"], SavingContributionQuery["order"]];
const contributionQuery = useMemo<SavingContributionQuery>(() => ({
sortBy: contributionSortBy,
order: contributionOrder,
page: contributionPage,
limit: CONTRIBUTION_PAGE_SIZE,
}), [contributionOrder, contributionPage, contributionSortBy]);
const goalQuery = useSavingGoal(goalId);
const contributionsQuery = useSavingContributions(goalId, contributionQuery);
const updateGoalMutation = useUpdateSavingGoal(goalId);
const archiveMutation = useArchiveSavingGoal();
const restoreMutation = useRestoreSavingGoal();
const createContributionMutation = useCreateSavingContribution(goalId);
const updateContributionMutation = useUpdateSavingContribution(goalId);
const deleteContributionMutation = useDeleteSavingContribution(goalId);
const goal = goalQuery.data?.data;
const contributions = contributionsQuery.data?.data || [];
const contributionTotalPages = contributionsQuery.data?.meta.totalPages || 0;
const isGoalActionPending = archiveMutation.isPending || restoreMutation.isPending || updateGoalMutation.isPending;
const isContributionMutationPending = createContributionMutation.isPending || updateContributionMutation.isPending;
const canContribute = Boolean(goal && !goal.isArchived && goal.status === "ACTIVE");
useEffect(() => {
if (contributionTotalPages > 0 && contributionPage > contributionTotalPages) {
setContributionPage(contributionTotalPages);
} else if (!contributionsQuery.isFetching && contributionsQuery.data && contributionTotalPages === 0 && contributionPage > 1) {
setContributionPage(1);
}
}, [contributionPage, contributionTotalPages, contributionsQuery.data, contributionsQuery.isFetching]);
const showError = (error: unknown, fallback: string) => openSnackbar({ type: "error", text: getErrorMessage(error, fallback) });
const handleUpdateGoal = (input: CreateSavingGoalInput) => {
if (!goal) return;
const changes: UpdateSavingGoalInput = {};
if (input.name !== goal.name) changes.name = input.name;
if (Number(input.targetAmount) !== Number(goal.targetAmount)) changes.targetAmount = input.targetAmount;
if (input.currency !== goal.currency) changes.currency = input.currency;
if (toLocalDate(input.targetDate) !== toLocalDate(goal.targetDate)) changes.targetDate = input.targetDate;
if ((input.description || null) !== goal.description) changes.description = input.description;
if ((input.icon || null) !== goal.icon) changes.icon = input.icon;
if ((input.color || null) !== goal.color) changes.color = input.color;
if (Object.keys(changes).length === 0) {
setIsEditOpen(false);
openSnackbar({ type: "info", text: t("savingGoal.noChanges") });
return;
}
updateGoalMutation.mutate(changes, {
onSuccess: () => {
setIsEditOpen(false);
openSnackbar({ type: "success", text: t("savingGoal.updateSuccess") });
},
onError: (error) => showError(error, t("savingGoal.updateFailed")),
});
};
const handleConfirmedGoalAction = () => {
if (!confirmAction) return;
if (confirmAction === "archive" || confirmAction === "restore") {
const mutation = confirmAction === "archive" ? archiveMutation : restoreMutation;
mutation.mutate(goalId, {
onSuccess: () => {
openSnackbar({ type: "success", text: t(`savingGoal.${confirmAction}Success`) });
setConfirmAction(null);
},
onError: (error) => showError(error, t(`savingGoal.${confirmAction}Failed`)),
});
return;
}
updateGoalMutation.mutate({ status: confirmAction === "pause" ? "PAUSED" : "ACTIVE" }, {
onSuccess: () => {
openSnackbar({ type: "success", text: t(`savingGoal.${confirmAction}Success`) });
setConfirmAction(null);
},
onError: (error) => showError(error, t(`savingGoal.${confirmAction}Failed`)),
});
};
const openCreateContribution = () => {
setEditingContribution(undefined);
setIsContributionOpen(true);
};
const openEditContribution = (contribution: SavingContribution) => {
setEditingContribution(contribution);
setIsContributionOpen(true);
};
const handleContributionSubmit = (input: SavingContributionInput) => {
if (editingContribution) {
updateContributionMutation.mutate({ contributionId: editingContribution.id, input }, {
onSuccess: () => {
setIsContributionOpen(false);
setEditingContribution(undefined);
openSnackbar({ type: "success", text: t("savingGoal.contribution.updateSuccess") });
},
onError: (error) => showError(error, t("savingGoal.contribution.updateFailed")),
});
} else {
createContributionMutation.mutate(input, {
onSuccess: () => {
setIsContributionOpen(false);
setContributionPage(1);
openSnackbar({ type: "success", text: t("savingGoal.contribution.createSuccess") });
},
onError: (error) => showError(error, t("savingGoal.contribution.createFailed")),
});
}
};
const handleDeleteContribution = () => {
if (!deletingContribution) return;
deleteContributionMutation.mutate(deletingContribution.id, {
onSuccess: () => {
setDeletingContribution(undefined);
openSnackbar({ type: "success", text: t("savingGoal.contribution.deleteSuccess") });
},
onError: (error) => showError(error, t("savingGoal.contribution.deleteFailed")),
});
};
const actionTitle = confirmAction ? t(`savingGoal.${confirmAction}Title`) : "";
const actionDescription = confirmAction ? t(`savingGoal.${confirmAction}Description`, { name: goal?.name || "" }) : "";
const progressType: BadgeType = goal?.status === "COMPLETED" ? "income" : goal?.progress.isOverdue ? "expense" : goal?.status === "PAUSED" ? "warning" : "primary";
return (
<Page className="page">
<Header title={t("savingGoal.detailHeader")} showBackIcon onBackClick={() => navigate("/saving-goals")} />
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
{goalQuery.isLoading && <><SavingGoalSkeleton /><SavingGoalSkeleton /></>}
{goalQuery.isError && (
<Card className="flex flex-col items-center gap-3 py-9 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-clay bg-clay-expense/15 font-baloo text-2xl font-bold text-clay-expense shadow-clay-pressed">!</div>
<div><h1 className="clay-title-h3">{t("savingGoal.detailLoadFailed")}</h1><p className="clay-caption mt-1">{getErrorMessage(goalQuery.error, t("savingGoal.missing"))}</p></div>
<div className="flex gap-3"><Button variant="ghost" className="text-sm" onClick={() => navigate("/saving-goals")}>{t("savingGoal.listButton")}</Button><Button variant="secondary" className="text-sm" onClick={() => goalQuery.refetch()}>{t("common.retry")}</Button></div>
</Card>
)}
{goal && (
<>
<Card className={`relative overflow-hidden p-6 ${goal.isArchived ? "opacity-75" : ""}`}>
<div className={`absolute -right-10 -top-10 h-36 w-36 rounded-full ${goal.status === "COMPLETED" ? "bg-clay-income/20" : goal.progress.isOverdue ? "bg-clay-expense/15" : "bg-clay-primary/10"}`} />
<div className="relative flex flex-col items-center text-center">
<CategoryArtwork icon={goal.icon || "gift"} color={goal.color || "#8B7CF6"} size="lg" archived={goal.isArchived} />
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<h1 className="clay-title-h2">{goal.name}</h1>
{goal.isArchived && <Badge type="info">{t("common.archived")}</Badge>}
<Badge type={statusStyle[goal.status]}>{t(`savingGoal.status.${goal.status.toLowerCase()}`)}</Badge>
</div>
{goal.description && <p className="mt-2 max-w-sm clay-caption">{goal.description}</p>}
<p className="mt-4 font-baloo text-3xl font-bold text-clay-primary-dark">{formatCurrency(Number(goal.targetAmount), goal.currency)}</p>
<p className="font-nunito text-xs font-bold uppercase tracking-wider text-clay-text-muted">{t("savingGoal.target")}</p>
</div>
</Card>
{goal.progress.isOverdue && !goal.isArchived && (
<Card className="flex items-start gap-3 !bg-clay-expense/15 p-4" role="alert">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-clay-expense font-baloo text-xl font-bold text-clay-on-status shadow-clay-raised">!</div>
<div><h2 className="font-baloo text-lg font-bold text-clay-text">{t("savingGoal.overdueTitle")}</h2><p className="clay-caption mt-1">{t("savingGoal.overdueDescription")}</p></div>
</Card>
)}
<Card className="p-5">
<div className="flex items-end justify-between gap-3">
<div><h2 className="clay-title-h3">{t("savingGoal.progress")}</h2><p className="clay-caption">{t(goal.status === "COMPLETED" ? "savingGoal.completedHint" : "savingGoal.progressHint", { days: goal.progress.daysRemaining })}</p></div>
<span className={`font-baloo text-3xl font-bold ${goal.progress.isOverdue ? "text-clay-expense" : goal.status === "COMPLETED" ? "text-clay-income" : "text-clay-primary"}`}>{formatNumber(Number(goal.progress.progressPercentage), { maximumFractionDigits: 1 })}%</span>
</div>
<ProgressBar className="mt-4" value={Number(goal.progress.progressPercentage)} type={progressType} />
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-clay-sm bg-clay-bg p-4 shadow-clay-pressed"><p className="clay-caption">{t("savingGoal.target")}</p><p className="mt-1 font-baloo text-lg font-bold text-clay-text">{formatCurrency(Number(goal.targetAmount), goal.currency)}</p></div>
<div className="rounded-clay-sm bg-clay-income/10 p-4 shadow-clay-pressed"><p className="clay-caption">{t("savingGoal.saved")}</p><p className="mt-1 font-baloo text-lg font-bold text-clay-income">{formatCurrency(Number(goal.progress.savedAmount), goal.currency)}</p></div>
<div className="col-span-2 rounded-clay-sm bg-clay-warning/10 p-4 shadow-clay-pressed sm:col-span-1"><p className="clay-caption">{t("savingGoal.remaining")}</p><p className="mt-1 font-baloo text-lg font-bold text-clay-warning">{formatCurrency(Number(goal.progress.remainingAmount), goal.currency)}</p></div>
</div>
</Card>
<Card className="p-5">
<h2 className="clay-title-h3 mb-4">{t("savingGoal.info")}</h2>
<dl className="divide-y divide-clay-text-muted/10">
<div className="flex justify-between gap-4 py-3"><dt className="clay-caption">{t("savingGoal.deadline")}</dt><dd className="text-right font-nunito text-sm font-bold text-clay-text">{formatDate(goal.targetDate, { day: "2-digit", month: "2-digit", year: "numeric" })}</dd></div>
<div className="flex justify-between gap-4 py-3"><dt className="clay-caption">{t("savingGoal.daysRemaining")}</dt><dd className={`font-nunito text-sm font-bold ${goal.progress.isOverdue ? "text-clay-expense" : "text-clay-text"}`}>{goal.progress.isOverdue ? t("savingGoal.overdue") : t("savingGoal.days", { count: goal.progress.daysRemaining })}</dd></div>
<div className="flex justify-between gap-4 py-3"><dt className="clay-caption">{t("savingGoal.contributionCount")}</dt><dd className="font-nunito text-sm font-bold text-clay-text">{goal.progress.contributionCount}</dd></div>
<div className="flex justify-between gap-4 py-3"><dt className="clay-caption">{t("savingGoal.lastContribution")}</dt><dd className="text-right font-nunito text-sm font-bold text-clay-text">{goal.progress.lastContributionAt ? formatDate(goal.progress.lastContributionAt, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }) : t("savingGoal.none")}</dd></div>
</dl>
</Card>
<Card className="p-5">
<div className="mb-4 flex items-start justify-between gap-3">
<div><h2 className="clay-title-h3">{t("savingGoal.contribution.title")}</h2><p className="clay-caption">{t("savingGoal.contribution.hint")}</p></div>
<Button className="shrink-0 gap-1 px-3 py-2 text-sm" disabled={!canContribute} onClick={openCreateContribution}><PlusIcon size={16} /> {t("savingGoal.contribution.addShort")}</Button>
</div>
{!canContribute && !goal.isArchived && <div className="mb-4 rounded-clay-sm bg-clay-warning/15 p-3 font-nunito text-xs font-semibold text-clay-text">{t(goal.status === "PAUSED" ? "savingGoal.contribution.pausedHint" : "savingGoal.contribution.completedHint")}</div>}
<Select
label={t("savingGoal.contribution.sort")}
value={contributionSort}
onChange={(event) => { setContributionSort(event.target.value); setContributionPage(1); }}
options={[
{ value: "contributedAt:desc", label: t("savingGoal.contribution.newest") },
{ value: "contributedAt:asc", label: t("savingGoal.contribution.oldest") },
{ value: "amount:desc", label: t("savingGoal.contribution.amountDesc") },
{ value: "amount:asc", label: t("savingGoal.contribution.amountAsc") },
]}
/>
{contributionsQuery.isLoading && <div className="mt-4 flex flex-col gap-3">{Array.from({ length: 3 }, (_, index) => <div key={index} className="h-20 animate-pulse rounded-clay-sm bg-clay-bg shadow-clay-pressed" />)}</div>}
{contributionsQuery.isError && (
<div className="mt-4 flex flex-col items-center gap-2 rounded-clay bg-clay-expense/10 p-5 text-center"><p className="font-baloo font-bold text-clay-text">{t("savingGoal.contribution.loadFailed")}</p><p className="clay-caption">{getErrorMessage(contributionsQuery.error, t("savingGoal.connectionFailed"))}</p><Button variant="secondary" className="mt-1 text-sm" onClick={() => contributionsQuery.refetch()}>{t("common.retry")}</Button></div>
)}
{!contributionsQuery.isLoading && !contributionsQuery.isError && contributions.length === 0 && (
<div className="mt-4 rounded-clay bg-clay-bg p-6 text-center shadow-clay-pressed"><p className="font-baloo font-bold text-clay-text">{t("savingGoal.contribution.empty")}</p><p className="clay-caption mt-1">{t("savingGoal.contribution.emptyHint")}</p>{canContribute && <Button className="mt-4 text-sm" onClick={openCreateContribution}>{t("savingGoal.contribution.addFirst")}</Button>}</div>
)}
{!contributionsQuery.isLoading && !contributionsQuery.isError && contributions.length > 0 && (
<div className="mt-4 divide-y divide-clay-text-muted/10">
{contributions.map((contribution) => (
<article key={contribution.id} className="flex items-start gap-3 py-4 first:pt-0 last:pb-0">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-clay-sm bg-clay-income/15 font-baloo text-lg font-bold text-clay-income shadow-clay-pressed">+</div>
<div className="min-w-0 flex-1"><p className="font-baloo text-lg font-bold text-clay-income">{formatCurrency(Number(contribution.amount), goal.currency)}</p><p className="clay-caption">{formatDate(contribution.contributedAt, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</p>{contribution.note && <p className="mt-1 break-words font-nunito text-sm text-clay-text">{contribution.note}</p>}</div>
{!goal.isArchived && <div className="flex shrink-0 flex-col gap-1"><Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => openEditContribution(contribution)}>{t("common.edit")}</Button><Button variant="ghost" className="px-2 py-1 text-xs text-clay-expense" onClick={() => setDeletingContribution(contribution)}>{t("common.delete")}</Button></div>}
</article>
))}
</div>
)}
{contributionTotalPages > 1 && <nav className="mt-5 flex items-center justify-between gap-3" aria-label={t("savingGoal.contribution.paginationLabel")}><Button variant="secondary" className="px-3 text-sm" disabled={contributionPage <= 1 || contributionsQuery.isFetching} onClick={() => setContributionPage((current) => current - 1)}>{t("common.previous")}</Button><span className="font-nunito text-sm font-bold text-clay-text">{t("common.pageOf", { page: contributionPage, total: contributionTotalPages })}</span><Button variant="secondary" className="px-3 text-sm" disabled={contributionPage >= contributionTotalPages || contributionsQuery.isFetching} onClick={() => setContributionPage((current) => current + 1)}>{t("common.next")}</Button></nav>}
</Card>
<Card className="flex flex-col gap-3 p-5">
<h2 className="clay-title-h3">{t("savingGoal.management")}</h2>
{!goal.isArchived ? (
<>
<Button fullWidth onClick={() => setIsEditOpen(true)}>{t("savingGoal.edit")}</Button>
{goal.status === "ACTIVE" && <Button variant="secondary" fullWidth onClick={() => setConfirmAction("pause")}>{t("savingGoal.pause")}</Button>}
{goal.status === "PAUSED" && <Button variant="secondary" fullWidth onClick={() => setConfirmAction("resume")}>{t("savingGoal.resume")}</Button>}
<Button variant="ghost" fullWidth className="text-clay-expense" onClick={() => setConfirmAction("archive")}>{t("savingGoal.archive")}</Button>
<p className="clay-caption text-center">{t("savingGoal.archiveHint")}</p>
</>
) : (
<><Button fullWidth disabled={restoreMutation.isPending} onClick={() => setConfirmAction("restore")}>{restoreMutation.isPending ? t("common.processing") : t("savingGoal.restore")}</Button><p className="clay-caption text-center">{t("savingGoal.restoreHint")}</p></>
)}
</Card>
</>
)}
</main>
{goal && !goal.isArchived && <SavingGoalFormModal isOpen={isEditOpen} goal={goal} isSubmitting={updateGoalMutation.isPending} onClose={() => setIsEditOpen(false)} onSubmit={handleUpdateGoal} />}
{goal && !goal.isArchived && <ContributionFormModal isOpen={isContributionOpen} contribution={editingContribution} currency={goal.currency} isSubmitting={isContributionMutationPending} onClose={() => { setIsContributionOpen(false); setEditingContribution(undefined); }} onSubmit={handleContributionSubmit} />}
<Modal
isOpen={Boolean(confirmAction)}
onClose={() => setConfirmAction(null)}
title={actionTitle}
footer={<><Button variant="ghost" className="px-4 text-sm" disabled={isGoalActionPending} onClick={() => setConfirmAction(null)}>{t("common.cancel")}</Button><Button className={`px-4 text-sm ${confirmAction === "archive" ? "bg-clay-expense" : ""}`} disabled={isGoalActionPending} onClick={handleConfirmedGoalAction}>{isGoalActionPending ? t("common.processing") : t(`savingGoal.${confirmAction}Confirm`)}</Button></>}
>
<p className="clay-body text-sm">{actionDescription}</p>
</Modal>
<Modal
isOpen={Boolean(deletingContribution)}
onClose={() => setDeletingContribution(undefined)}
title={t("savingGoal.contribution.deleteTitle")}
footer={<><Button variant="ghost" className="px-4 text-sm" disabled={deleteContributionMutation.isPending} onClick={() => setDeletingContribution(undefined)}>{t("common.cancel")}</Button><Button className="bg-clay-expense px-4 text-sm" disabled={deleteContributionMutation.isPending} onClick={handleDeleteContribution}>{deleteContributionMutation.isPending ? t("common.processing") : t("common.delete")}</Button></>}
>
<p className="clay-body text-sm">{t("savingGoal.contribution.deleteDescription", { amount: deletingContribution && goal ? formatCurrency(Number(deletingContribution.amount), goal.currency) : "" })}</p>
</Modal>
</Page>
);
};
export default SavingGoalDetailPage;
import React, { useDeferredValue, useEffect, useMemo, useState } from "react";
import { Header, Page, useNavigate, useSnackbar } from "zmp-ui";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Select } from "@/components/ui/Select";
import { Tabs } from "@/components/ui/Tabs";
import { IconGradients, PlusIcon, SavingGoalIcon } from "@/components/ui/icons";
import { useCreateSavingGoal, useSavingGoals } from "@/hooks/use-saving-goals";
import { useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
import { toLocalDate } from "@/lib/money-input";
import {
CreateSavingGoalInput,
SavingGoalQuery,
SavingGoalSortField,
SavingGoalSortOrder,
SavingGoalStatus,
} from "@/types/saving-goal";
import { SavingGoalCard } from "./components/SavingGoalCard";
import { SavingGoalFormModal } from "./components/SavingGoalFormModal";
import { SavingGoalSkeleton } from "./components/SavingGoalSkeleton";
const PAGE_SIZE = 6;
type StatusFilter = "ALL" | SavingGoalStatus;
type DueFilter = "ALL" | "OVERDUE" | "NEXT_30_DAYS" | "CUSTOM";
function endOfLocalDay(value: string): string | undefined {
return value ? new Date(`${value}T23:59:59.999`).toISOString() : undefined;
}
function startOfLocalDay(value: string): string | undefined {
return value ? new Date(`${value}T00:00:00`).toISOString() : undefined;
}
const SavingGoalsPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const { t } = useI18n();
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search.trim());
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
const [dueFilter, setDueFilter] = useState<DueFilter>("ALL");
const [dueFrom, setDueFrom] = useState("");
const [dueTo, setDueTo] = useState("");
const [includeArchived, setIncludeArchived] = useState(false);
const [sort, setSort] = useState("targetDate:asc");
const [page, setPage] = useState(1);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [sortBy, order] = sort.split(":") as [SavingGoalSortField, SavingGoalSortOrder];
const dueRange = useMemo(() => {
if (dueFilter === "OVERDUE") return { dueTo: new Date().toISOString() };
if (dueFilter === "NEXT_30_DAYS") {
const end = new Date();
end.setDate(end.getDate() + 30);
return { dueFrom: new Date().toISOString(), dueTo: end.toISOString() };
}
if (dueFilter === "CUSTOM") return { dueFrom: startOfLocalDay(dueFrom), dueTo: endOfLocalDay(dueTo) };
return {};
}, [dueFilter, dueFrom, dueTo]);
const query = useMemo<SavingGoalQuery>(() => ({
...(deferredSearch ? { search: deferredSearch } : {}),
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
...(dueRange.dueFrom ? { dueFrom: dueRange.dueFrom } : {}),
...(dueRange.dueTo ? { dueTo: dueRange.dueTo } : {}),
includeArchived,
sortBy,
order,
page,
limit: PAGE_SIZE,
}), [deferredSearch, dueRange.dueFrom, dueRange.dueTo, includeArchived, order, page, sortBy, statusFilter]);
const goalsQuery = useSavingGoals(query);
const createMutation = useCreateSavingGoal();
const goals = goalsQuery.data?.data || [];
const totalItems = goalsQuery.data?.meta.total || 0;
const totalPages = goalsQuery.data?.meta.totalPages || 0;
const completedOnPage = goals.filter((goal) => goal.status === "COMPLETED" && !goal.isArchived).length;
useEffect(() => setPage(1), [deferredSearch, dueFilter, dueFrom, dueTo, includeArchived, sort, statusFilter]);
useEffect(() => {
if (totalPages > 0 && page > totalPages) setPage(totalPages);
else if (!goalsQuery.isFetching && goalsQuery.data && totalPages === 0 && page > 1) setPage(1);
}, [goalsQuery.data, goalsQuery.isFetching, page, totalPages]);
const isFiltered = Boolean(deferredSearch) || statusFilter !== "ALL" || dueFilter !== "ALL" || includeArchived;
const resetFilters = () => {
setSearch("");
setStatusFilter("ALL");
setDueFilter("ALL");
setDueFrom("");
setDueTo("");
setIncludeArchived(false);
setSort("targetDate:asc");
};
const handleCreate = (input: CreateSavingGoalInput) => {
createMutation.mutate(input, {
onSuccess: (response) => {
setIsCreateOpen(false);
openSnackbar({ type: "success", text: t("savingGoal.createSuccess") });
navigate(`/saving-goals/${response.data.id}`);
},
onError: (error) => openSnackbar({ type: "error", text: getErrorMessage(error, t("savingGoal.createFailed")) }),
});
};
return (
<Page className="page">
<Header title={t("savingGoal.header")} showBackIcon onBackClick={() => navigate("/")} />
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
<Card className="relative overflow-hidden !bg-clay-primary-dark p-5 !text-clay-on-primary">
<div className="absolute -right-8 -top-8 h-32 w-32 rounded-full bg-clay-income/25" />
<div className="absolute -bottom-12 left-24 h-28 w-28 rounded-full bg-clay-on-primary/10" />
<div className="relative flex items-start justify-between gap-4">
<div>
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-clay bg-clay-income shadow-clay-raised"><SavingGoalIcon size={29} /></div>
<p className="font-nunito text-sm font-bold text-clay-on-primary">{t("savingGoal.overview")}</p>
<p className="font-baloo text-3xl font-bold text-clay-on-primary">{goalsQuery.isLoading ? "—" : totalItems}</p>
<p className="font-nunito text-xs font-semibold text-clay-on-primary/80">{t("savingGoal.resultCount")}</p>
{completedOnPage > 0 && <span className="mt-3 inline-flex rounded-full bg-clay-income px-3 py-1 font-nunito text-xs font-bold text-clay-on-status">{t("savingGoal.completedCount", { count: completedOnPage })}</span>}
</div>
<Button variant="secondary" className="gap-1 px-4 py-2 text-sm" onClick={() => setIsCreateOpen(true)}><PlusIcon size={17} /> {t("savingGoal.createShort")}</Button>
</div>
</Card>
<Card className="flex flex-col gap-4 p-4">
<Tabs
activeTab={statusFilter}
onChange={(key) => setStatusFilter(key as StatusFilter)}
tabs={[
{ key: "ALL", label: t("savingGoal.status.all") },
{ key: "ACTIVE", label: t("savingGoal.status.active") },
{ key: "PAUSED", label: t("savingGoal.status.paused") },
{ key: "COMPLETED", label: t("savingGoal.status.completed") },
]}
/>
<Input
label={t("savingGoal.filters.searchLabel")}
value={search}
maxLength={200}
placeholder={t("savingGoal.filters.searchPlaceholder")}
onChange={(event) => setSearch(event.target.value)}
endAdornment={<svg aria-hidden="true" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round"><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></svg>}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Select
label={t("savingGoal.filters.deadline")}
value={dueFilter}
onChange={(event) => setDueFilter(event.target.value as DueFilter)}
options={[
{ value: "ALL", label: t("savingGoal.filters.allDeadlines") },
{ value: "OVERDUE", label: t("savingGoal.filters.overdue") },
{ value: "NEXT_30_DAYS", label: t("savingGoal.filters.next30Days") },
{ value: "CUSTOM", label: t("savingGoal.filters.custom") },
]}
/>
<Select
label={t("savingGoal.filters.sort")}
value={sort}
onChange={(event) => setSort(event.target.value)}
options={[
{ value: "targetDate:asc", label: t("savingGoal.sort.deadlineAsc") },
{ value: "targetDate:desc", label: t("savingGoal.sort.deadlineDesc") },
{ value: "targetAmount:desc", label: t("savingGoal.sort.amountDesc") },
{ value: "targetAmount:asc", label: t("savingGoal.sort.amountAsc") },
{ value: "name:asc", label: t("savingGoal.sort.nameAsc") },
{ value: "updatedAt:desc", label: t("savingGoal.sort.updatedDesc") },
]}
/>
</div>
{dueFilter === "CUSTOM" && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input type="date" label={t("savingGoal.filters.dueFrom")} max={dueTo || undefined} value={dueFrom} onChange={(event) => { const value = event.target.value; setDueFrom(value); if (dueTo && value > dueTo) setDueTo(value); }} />
<Input type="date" label={t("savingGoal.filters.dueTo")} min={dueFrom || undefined} value={dueTo} onChange={(event) => { const value = event.target.value; setDueTo(value); if (dueFrom && value < dueFrom) setDueFrom(value); }} />
</div>
)}
<label className="flex cursor-pointer items-center gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<input type="checkbox" checked={includeArchived} className="h-4 w-4 accent-clay-primary" onChange={(event) => setIncludeArchived(event.target.checked)} />
<span>
<span className="block font-nunito text-sm font-bold text-clay-text">{t("savingGoal.filters.includeArchived")}</span>
<span className="clay-caption block">{t("savingGoal.filters.includeArchivedHint")}</span>
</span>
</label>
{isFiltered && <Button variant="ghost" className="self-end px-3 text-sm" onClick={resetFilters}>{t("savingGoal.filters.reset")}</Button>}
</Card>
<section aria-labelledby="saving-goal-list-heading" className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3 px-1">
<div><h2 id="saving-goal-list-heading" className="clay-title-h2">{t("savingGoal.list")}</h2><p className="clay-caption">{t("common.results", { count: totalItems })}</p></div>
{goalsQuery.isFetching && !goalsQuery.isLoading && <span className="clay-caption animate-pulse">{t("savingGoal.syncing")}</span>}
</div>
{goalsQuery.isLoading && <div className="flex flex-col gap-3" aria-label={t("savingGoal.loading")}><SavingGoalSkeleton /><SavingGoalSkeleton /><SavingGoalSkeleton /></div>}
{goalsQuery.isError && (
<Card className="flex flex-col items-center gap-3 py-9 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-clay bg-clay-expense/15 font-baloo text-2xl font-bold text-clay-expense shadow-clay-pressed">!</div>
<div><h3 className="clay-title-h3">{t("savingGoal.loadFailed")}</h3><p className="clay-caption mt-1">{getErrorMessage(goalsQuery.error, t("savingGoal.connectionFailed"))}</p></div>
<Button variant="secondary" className="text-sm" onClick={() => goalsQuery.refetch()}>{t("common.retry")}</Button>
</Card>
)}
{!goalsQuery.isLoading && !goalsQuery.isError && goals.length === 0 && (
<Card className="flex flex-col items-center gap-4 py-9 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-clay-lg bg-clay-income/20 shadow-clay-pressed"><SavingGoalIcon size={34} /></div>
<div><h3 className="clay-title-h3">{isFiltered ? t("savingGoal.notFound") : t("savingGoal.empty")}</h3><p className="clay-caption mt-1 max-w-xs">{isFiltered ? t("savingGoal.filteredHint") : t("savingGoal.emptyHint")}</p></div>
{isFiltered ? <Button variant="secondary" className="text-sm" onClick={resetFilters}>{t("savingGoal.filters.reset")}</Button> : <Button className="text-sm" onClick={() => setIsCreateOpen(true)}>{t("savingGoal.createFirst")}</Button>}
</Card>
)}
{goals.map((goal) => <SavingGoalCard key={goal.id} goal={goal} onClick={() => navigate(`/saving-goals/${goal.id}`)} />)}
{totalPages > 1 && (
<nav className="flex items-center justify-between gap-3 pt-2" aria-label={t("savingGoal.paginationLabel")}>
<Button variant="secondary" className="px-4 text-sm" disabled={page <= 1 || goalsQuery.isFetching} onClick={() => setPage((current) => current - 1)}>{t("common.previous")}</Button>
<span className="font-nunito text-sm font-bold text-clay-text">{t("common.pageOf", { page, total: totalPages })}</span>
<Button variant="secondary" className="px-4 text-sm" disabled={page >= totalPages || goalsQuery.isFetching} onClick={() => setPage((current) => current + 1)}>{t("common.next")}</Button>
</nav>
)}
</section>
</main>
<SavingGoalFormModal isOpen={isCreateOpen} isSubmitting={createMutation.isPending} onClose={() => setIsCreateOpen(false)} onSubmit={handleCreate} />
</Page>
);
};
export default SavingGoalsPage;
import { apiClient } from "@/lib/api-client";
import {
CreateSavingGoalInput,
DeleteSavingContributionResponse,
SavingContributionInput,
SavingContributionListResponse,
SavingContributionMutationResponse,
SavingContributionQuery,
SavingGoalListResponse,
SavingGoalQuery,
SavingGoalResponse,
UpdateSavingContributionInput,
UpdateSavingGoalInput,
} from "@/types/saving-goal";
function ensureSuccess<T extends { success: boolean; message?: string }>(response: T): T {
if (!response.success) {
throw new Error(response.message || "Saving goal request failed");
}
return response;
}
export const savingGoalService = {
async getGoals(query: SavingGoalQuery): Promise<SavingGoalListResponse> {
const response = await apiClient.get<SavingGoalListResponse>("/saving-goals", { params: query });
return ensureSuccess(response.data);
},
async getGoal(id: string): Promise<SavingGoalResponse> {
const response = await apiClient.get<SavingGoalResponse>(`/saving-goals/${id}`);
return ensureSuccess(response.data);
},
async createGoal(input: CreateSavingGoalInput): Promise<SavingGoalResponse> {
const response = await apiClient.post<SavingGoalResponse>("/saving-goals", input);
return ensureSuccess(response.data);
},
async updateGoal(id: string, input: UpdateSavingGoalInput): Promise<SavingGoalResponse> {
const response = await apiClient.put<SavingGoalResponse>(`/saving-goals/${id}`, input);
return ensureSuccess(response.data);
},
async archiveGoal(id: string): Promise<SavingGoalResponse> {
const response = await apiClient.delete<SavingGoalResponse>(`/saving-goals/${id}`);
return ensureSuccess(response.data);
},
async restoreGoal(id: string): Promise<SavingGoalResponse> {
const response = await apiClient.patch<SavingGoalResponse>(`/saving-goals/${id}/restore`);
return ensureSuccess(response.data);
},
async getContributions(id: string, query: SavingContributionQuery): Promise<SavingContributionListResponse> {
const response = await apiClient.get<SavingContributionListResponse>(`/saving-goals/${id}/contributions`, { params: query });
return ensureSuccess(response.data);
},
async createContribution(id: string, input: SavingContributionInput): Promise<SavingContributionMutationResponse> {
const response = await apiClient.post<SavingContributionMutationResponse>(`/saving-goals/${id}/contributions`, input);
return ensureSuccess(response.data);
},
async updateContribution(id: string, contributionId: string, input: UpdateSavingContributionInput): Promise<SavingContributionMutationResponse> {
const response = await apiClient.put<SavingContributionMutationResponse>(`/saving-goals/${id}/contributions/${contributionId}`, input);
return ensureSuccess(response.data);
},
async deleteContribution(id: string, contributionId: string): Promise<DeleteSavingContributionResponse> {
const response = await apiClient.delete<DeleteSavingContributionResponse>(`/saving-goals/${id}/contributions/${contributionId}`);
return ensureSuccess(response.data);
},
};
import { PaginationMeta } from "./wallet";
export type SavingGoalStatus = "ACTIVE" | "PAUSED" | "COMPLETED";
export type SavingGoalSortField =
| "name"
| "targetAmount"
| "targetDate"
| "createdAt"
| "updatedAt";
export type SavingContributionSortField =
| "amount"
| "contributedAt"
| "createdAt"
| "updatedAt";
export type SavingGoalSortOrder = "asc" | "desc";
export interface SavingGoalProgress {
savedAmount: string;
remainingAmount: string;
progressPercentage: string;
contributionCount: number;
lastContributionAt: string | null;
daysRemaining: number;
isOverdue: boolean;
}
export interface SavingGoal {
id: string;
name: string;
targetAmount: string;
currency: string;
targetDate: string;
description: string | null;
icon: string | null;
color: string | null;
status: SavingGoalStatus;
completedAt: string | null;
isArchived: boolean;
createdAt: string;
updatedAt: string;
progress: SavingGoalProgress;
}
export interface SavingGoalQuery {
search?: string;
status?: SavingGoalStatus;
dueFrom?: string;
dueTo?: string;
includeArchived: boolean;
sortBy: SavingGoalSortField;
order: SavingGoalSortOrder;
page: number;
limit: number;
}
export interface CreateSavingGoalInput {
name: string;
targetAmount: string;
currency: string;
targetDate: string;
description?: string | null;
icon?: string | null;
color?: string | null;
}
export interface UpdateSavingGoalInput extends Partial<CreateSavingGoalInput> {
status?: Extract<SavingGoalStatus, "ACTIVE" | "PAUSED">;
}
export interface SavingContribution {
id: string;
savingGoalId: string;
amount: string;
contributedAt: string;
note: string | null;
createdAt: string;
updatedAt: string;
}
export interface SavingContributionQuery {
dateFrom?: string;
dateTo?: string;
sortBy: SavingContributionSortField;
order: SavingGoalSortOrder;
page: number;
limit: number;
}
export interface SavingContributionInput {
amount: string;
contributedAt: string;
note?: string | null;
}
export type UpdateSavingContributionInput = Partial<SavingContributionInput>;
export interface SavingGoalListResponse {
success: boolean;
message?: string;
data: SavingGoal[];
meta: PaginationMeta;
errors?: unknown[] | null;
}
export interface SavingGoalResponse {
success: boolean;
message?: string;
data: SavingGoal;
errors?: unknown[] | null;
}
export interface SavingContributionListResponse {
success: boolean;
message?: string;
data: SavingContribution[];
meta: PaginationMeta;
errors?: unknown[] | null;
}
export interface SavingContributionMutationData {
contribution: SavingContribution;
goal: SavingGoal;
}
export interface SavingContributionMutationResponse {
success: boolean;
message?: string;
data: SavingContributionMutationData;
errors?: unknown[] | null;
}
export interface DeleteSavingContributionResponse {
success: boolean;
message?: string;
data: { id: string; goal: SavingGoal };
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