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 ...@@ -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`. - **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.
- **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. - **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. - **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). - **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 ...@@ -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ý 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ý 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. - 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 ## Khi cập nhật file này
......
...@@ -31,6 +31,8 @@ import TransactionsPage from "@/pages/transactions/index"; ...@@ -31,6 +31,8 @@ import TransactionsPage from "@/pages/transactions/index";
import TransfersPage from "@/pages/transfers/index"; import TransfersPage from "@/pages/transfers/index";
import BudgetsPage from "@/pages/budgets/index"; import BudgetsPage from "@/pages/budgets/index";
import BudgetDetailPage from "@/pages/budgets/detail"; 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 AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore(); const { setAuth, clearAuth, setInitialized } = useAuthStore();
...@@ -96,6 +98,8 @@ const Layout = () => { ...@@ -96,6 +98,8 @@ const Layout = () => {
<Route path="/transfers" element={<AuthGuard><TransfersPage /></AuthGuard>}></Route> <Route path="/transfers" element={<AuthGuard><TransfersPage /></AuthGuard>}></Route>
<Route path="/budgets" element={<AuthGuard><BudgetsPage /></AuthGuard>}></Route> <Route path="/budgets" element={<AuthGuard><BudgetsPage /></AuthGuard>}></Route>
<Route path="/budgets/:id" element={<AuthGuard><BudgetDetailPage /></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> <Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes> </AnimationRoutes>
</AuthInitializer> </AuthInitializer>
......
...@@ -14,6 +14,12 @@ const dimensions = { ...@@ -14,6 +14,12 @@ const dimensions = {
}; };
function CategoryGlyph({ icon }: { icon: string }) { 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)) { if (["briefcase", "work"].includes(icon)) {
return <><rect x="3" y="7" width="18" height="13" rx="3" /><path d="M9 7V5h6v2M3 12h18M10 12v2h4v-2" /></>; 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<{ ...@@ -21,6 +21,8 @@ const PAGE_TITLES: ReadonlyArray<{
{ matches: (pathname) => pathname === "/transfers", key: "document.transfers" }, { matches: (pathname) => pathname === "/transfers", key: "document.transfers" },
{ matches: (pathname) => pathname === "/budgets", key: "document.budgets" }, { matches: (pathname) => pathname === "/budgets", key: "document.budgets" },
{ matches: (pathname) => pathname.startsWith("/budgets/"), key: "document.budgetDetail" }, { 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" }, { matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" },
]; ];
......
...@@ -32,7 +32,7 @@ export const Tabs: React.FC<TabsProps> = ({ ...@@ -32,7 +32,7 @@ export const Tabs: React.FC<TabsProps> = ({
key={tab.key} key={tab.key}
onClick={() => onChange(tab.key)} onClick={() => onChange(tab.key)}
className={` 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 transition-all duration-200 ease-in-out focus:outline-none z-10
${ ${
isActive 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 @@ ...@@ -48,7 +48,9 @@
"transactions": "Transactions", "transactions": "Transactions",
"transfers": "Transfers", "transfers": "Transfers",
"budgets": "Budget Management", "budgets": "Budget Management",
"budgetDetail": "Budget Details" "budgetDetail": "Budget Details",
"savingGoals": "Saving Goals",
"savingGoalDetail": "Saving Goal Details"
}, },
"validation": { "validation": {
"emailRequired": "Email is required", "emailRequired": "Email is required",
...@@ -99,7 +101,18 @@ ...@@ -99,7 +101,18 @@
"budgetCategoryRequired": "Please select an expense category", "budgetCategoryRequired": "Please select an expense category",
"budgetStartDateRequired": "Please select a start date", "budgetStartDateRequired": "Please select a start date",
"budgetEndDateRequired": "Please select an end 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": { "auth": {
"email": "Email", "email": "Email",
...@@ -183,7 +196,8 @@ ...@@ -183,7 +196,8 @@
"styleGuide": "Explore Style Guide", "styleGuide": "Explore Style Guide",
"transactions": "Manage transactions", "transactions": "Manage transactions",
"transfers": "Transfer between wallets", "transfers": "Transfer between wallets",
"budgets": "Manage spending budgets" "budgets": "Manage spending budgets",
"savingGoals": "Manage saving goals"
}, },
"profile": { "profile": {
"header": "Account", "header": "Account",
...@@ -557,6 +571,168 @@ ...@@ -557,6 +571,168 @@
"increaseThreshold": "Increase alert threshold" "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": { "styleGuide": {
"header": "Style Guide & Design System", "header": "Style Guide & Design System",
"title": "Claymorphism Theme System", "title": "Claymorphism Theme System",
......
This diff is collapsed.
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() { ...@@ -81,6 +81,13 @@ function HomePage() {
> >
{t("home.budgets")} {t("home.budgets")}
</Button> </Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/saving-goals")}
>
{t("home.savingGoals")}
</Button>
<Button <Button
variant="secondary" variant="secondary"
fullWidth 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>
);
This diff is collapsed.
This diff is collapsed.
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