feat: 初始化财务管理应用前端项目,包含账户、预算、交易、报表、设置等核心功能模块。
This commit is contained in:
1
copy/src/services/.gitkeep
Normal file
1
copy/src/services/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# API service layer - HTTP client and API calls
|
||||
158
copy/src/services/accountService.ts
Normal file
158
copy/src/services/accountService.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Account Service - API calls for account management
|
||||
*/
|
||||
|
||||
import api from './api';
|
||||
import type { Account, AccountFormInput, TransferFormInput, ApiResponse } from '../types';
|
||||
|
||||
/**
|
||||
* Get all accounts
|
||||
*/
|
||||
export async function getAccounts(): Promise<Account[]> {
|
||||
const response = await api.get<ApiResponse<Account[]>>('/accounts');
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single account by ID
|
||||
*/
|
||||
export async function getAccount(id: number): Promise<Account> {
|
||||
const response = await api.get<ApiResponse<Account>>(`/accounts/${id}`);
|
||||
if (!response.data) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new account
|
||||
*/
|
||||
export async function createAccount(data: AccountFormInput): Promise<Account> {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const payload = {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
balance: data.balance,
|
||||
currency: data.currency,
|
||||
icon: data.icon,
|
||||
billing_date: data.billingDate,
|
||||
payment_date: data.paymentDate,
|
||||
};
|
||||
const response = await api.post<ApiResponse<Account>>('/accounts', payload);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to create account');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing account
|
||||
*/
|
||||
export async function updateAccount(id: number, data: Partial<AccountFormInput>): Promise<Account> {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) payload.name = data.name;
|
||||
if (data.type !== undefined) payload.type = data.type;
|
||||
if (data.balance !== undefined) payload.balance = data.balance;
|
||||
if (data.currency !== undefined) payload.currency = data.currency;
|
||||
if (data.icon !== undefined) payload.icon = data.icon;
|
||||
if (data.billingDate !== undefined) payload.billing_date = data.billingDate;
|
||||
if (data.paymentDate !== undefined) payload.payment_date = data.paymentDate;
|
||||
|
||||
const response = await api.put<ApiResponse<Account>>(`/accounts/${id}`, payload);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to update account');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an account
|
||||
*/
|
||||
export async function deleteAccount(id: number): Promise<void> {
|
||||
await api.delete<ApiResponse<void>>(`/accounts/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer between accounts
|
||||
*/
|
||||
export async function transferBetweenAccounts(data: TransferFormInput): Promise<void> {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const payload = {
|
||||
from_account_id: data.fromAccountId,
|
||||
to_account_id: data.toAccountId,
|
||||
amount: data.amount,
|
||||
note: data.note,
|
||||
};
|
||||
const response = await api.post<ApiResponse<void>>('/accounts/transfer', payload);
|
||||
if (!response.success && response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounts grouped by type
|
||||
*/
|
||||
export function groupAccountsByType(accounts: Account[] | undefined): Record<string, Account[]> {
|
||||
if (!accounts || !Array.isArray(accounts)) {
|
||||
return {};
|
||||
}
|
||||
return accounts.reduce(
|
||||
(groups, account) => {
|
||||
const type = account.type;
|
||||
if (!groups[type]) {
|
||||
groups[type] = [];
|
||||
}
|
||||
groups[type].push(account);
|
||||
return groups;
|
||||
},
|
||||
{} as Record<string, Account[]>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total balance for accounts
|
||||
*/
|
||||
export function calculateTotalBalance(accounts: Account[] | undefined): number {
|
||||
if (!accounts || !Array.isArray(accounts)) {
|
||||
return 0;
|
||||
}
|
||||
return accounts.reduce((total, account) => total + account.balance, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total assets (positive balances)
|
||||
*/
|
||||
export function calculateTotalAssets(accounts: Account[] | undefined): number {
|
||||
if (!accounts || !Array.isArray(accounts)) {
|
||||
return 0;
|
||||
}
|
||||
return accounts
|
||||
.filter((account) => account.balance > 0)
|
||||
.reduce((total, account) => total + account.balance, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total liabilities (negative balances)
|
||||
*/
|
||||
export function calculateTotalLiabilities(accounts: Account[] | undefined): number {
|
||||
if (!accounts || !Array.isArray(accounts)) {
|
||||
return 0;
|
||||
}
|
||||
return accounts
|
||||
.filter((account) => account.balance < 0)
|
||||
.reduce((total, account) => total + Math.abs(account.balance), 0);
|
||||
}
|
||||
|
||||
export default {
|
||||
getAccounts,
|
||||
getAccount,
|
||||
createAccount,
|
||||
updateAccount,
|
||||
deleteAccount,
|
||||
transferBetweenAccounts,
|
||||
groupAccountsByType,
|
||||
calculateTotalBalance,
|
||||
calculateTotalAssets,
|
||||
calculateTotalLiabilities,
|
||||
};
|
||||
247
copy/src/services/allocationRuleService.ts
Normal file
247
copy/src/services/allocationRuleService.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Allocation Rule Service
|
||||
* Handles API calls for income allocation rules
|
||||
*/
|
||||
|
||||
import api from './api';
|
||||
import type { AllocationRule, AllocationTarget, Account, AccountType, CurrencyCode } from '../types';
|
||||
|
||||
/**
|
||||
* Transform API response to frontend format (snake_case to camelCase)
|
||||
*/
|
||||
function transformAllocationRule(data: Record<string, unknown>): AllocationRule {
|
||||
return {
|
||||
id: data.id as number,
|
||||
name: data.name as string,
|
||||
triggerType: (data.trigger_type ?? data.triggerType) as string,
|
||||
sourceAccountId: (data.source_account_id ?? data.sourceAccountId) as number | undefined,
|
||||
sourceAccount: data.source_account ? transformSourceAccount(data.source_account as Record<string, unknown>) : undefined,
|
||||
isActive: (data.is_active ?? data.isActive) as boolean,
|
||||
targets: ((data.targets as Record<string, unknown>[]) || []).map(transformAllocationTarget),
|
||||
createdAt: (data.created_at ?? data.createdAt) as string,
|
||||
};
|
||||
}
|
||||
|
||||
function transformSourceAccount(data: Record<string, unknown>): Account {
|
||||
return {
|
||||
id: data.id as number,
|
||||
name: data.name as string,
|
||||
type: data.type as AccountType,
|
||||
balance: data.balance as number,
|
||||
currency: data.currency as CurrencyCode,
|
||||
icon: data.icon as string,
|
||||
isCredit: (data.is_credit ?? data.isCredit) as boolean,
|
||||
billingDate: (data.billing_date ?? data.billingDate) as number | undefined,
|
||||
paymentDate: (data.payment_date ?? data.paymentDate) as number | undefined,
|
||||
createdAt: (data.created_at ?? data.createdAt) as string,
|
||||
updatedAt: (data.updated_at ?? data.updatedAt) as string,
|
||||
};
|
||||
}
|
||||
|
||||
function transformAllocationTarget(data: Record<string, unknown>): AllocationTarget {
|
||||
return {
|
||||
id: data.id as number,
|
||||
ruleId: (data.rule_id ?? data.ruleId) as number,
|
||||
targetType: (data.target_type ?? data.targetType) as 'account' | 'piggy_bank',
|
||||
targetId: (data.target_id ?? data.targetId) as number,
|
||||
percentage: data.percentage as number | undefined,
|
||||
fixedAmount: (data.fixed_amount ?? data.fixedAmount) as number | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Form input for creating/updating allocation rules
|
||||
*/
|
||||
export interface AllocationRuleFormInput {
|
||||
name: string;
|
||||
triggerType: 'income' | 'manual';
|
||||
sourceAccountId?: number;
|
||||
isActive: boolean;
|
||||
targets: AllocationTargetInput[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Form input for allocation targets
|
||||
*/
|
||||
export interface AllocationTargetInput {
|
||||
targetType: 'account' | 'piggy_bank';
|
||||
targetId: number;
|
||||
percentage?: number;
|
||||
fixedAmount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of applying an allocation rule
|
||||
*/
|
||||
export interface AllocationResult {
|
||||
ruleId: number;
|
||||
ruleName: string;
|
||||
totalAmount: number;
|
||||
allocatedAmount: number;
|
||||
remaining: number;
|
||||
allocations: AllocationDetail[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail of a single allocation
|
||||
*/
|
||||
export interface AllocationDetail {
|
||||
targetType: 'account' | 'piggy_bank';
|
||||
targetId: number;
|
||||
targetName: string;
|
||||
amount: number;
|
||||
percentage?: number;
|
||||
fixedAmount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for applying an allocation rule
|
||||
*/
|
||||
export interface ApplyAllocationInput {
|
||||
amount: number;
|
||||
fromAccountId?: number;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all allocation rules
|
||||
*/
|
||||
export const getAllocationRules = async (): Promise<AllocationRule[]> => {
|
||||
const response = await api.get<{ success: boolean; data: Record<string, unknown>[] }>('/allocation-rules');
|
||||
return (response.data || []).map(transformAllocationRule);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get active allocation rules only
|
||||
*/
|
||||
export const getActiveAllocationRules = async (): Promise<AllocationRule[]> => {
|
||||
const response = await api.get<{ success: boolean; data: Record<string, unknown>[] }>('/allocation-rules?active=true');
|
||||
return (response.data || []).map(transformAllocationRule);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single allocation rule by ID
|
||||
*/
|
||||
export const getAllocationRule = async (id: number): Promise<AllocationRule> => {
|
||||
const response = await api.get<{ success: boolean; data: Record<string, unknown> }>(`/allocation-rules/${id}`);
|
||||
if (!response.data) {
|
||||
throw new Error('Allocation rule not found');
|
||||
}
|
||||
return transformAllocationRule(response.data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new allocation rule
|
||||
*/
|
||||
export const createAllocationRule = async (
|
||||
data: AllocationRuleFormInput
|
||||
): Promise<AllocationRule> => {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const payload = {
|
||||
name: data.name,
|
||||
trigger_type: data.triggerType,
|
||||
source_account_id: data.sourceAccountId,
|
||||
is_active: data.isActive,
|
||||
targets: data.targets.map(t => ({
|
||||
target_type: t.targetType,
|
||||
target_id: t.targetId,
|
||||
percentage: t.percentage,
|
||||
fixed_amount: t.fixedAmount,
|
||||
})),
|
||||
};
|
||||
const response = await api.post<{ success: boolean; data: Record<string, unknown> }>('/allocation-rules', payload);
|
||||
if (!response.data) {
|
||||
throw new Error('Failed to create allocation rule');
|
||||
}
|
||||
return transformAllocationRule(response.data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing allocation rule
|
||||
*/
|
||||
export const updateAllocationRule = async (
|
||||
id: number,
|
||||
data: AllocationRuleFormInput
|
||||
): Promise<AllocationRule> => {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const payload = {
|
||||
name: data.name,
|
||||
trigger_type: data.triggerType,
|
||||
source_account_id: data.sourceAccountId,
|
||||
is_active: data.isActive,
|
||||
targets: data.targets.map(t => ({
|
||||
target_type: t.targetType,
|
||||
target_id: t.targetId,
|
||||
percentage: t.percentage,
|
||||
fixed_amount: t.fixedAmount,
|
||||
})),
|
||||
};
|
||||
const response = await api.put<{ success: boolean; data: Record<string, unknown> }>(`/allocation-rules/${id}`, payload);
|
||||
if (!response.data) {
|
||||
throw new Error('Failed to update allocation rule');
|
||||
}
|
||||
return transformAllocationRule(response.data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an allocation rule
|
||||
*/
|
||||
export const deleteAllocationRule = async (id: number): Promise<void> => {
|
||||
await api.delete(`/allocation-rules/${id}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply an allocation rule to distribute an amount
|
||||
*/
|
||||
export const applyAllocationRule = async (
|
||||
id: number,
|
||||
data: ApplyAllocationInput
|
||||
): Promise<AllocationResult> => {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const payload = {
|
||||
amount: data.amount,
|
||||
from_account_id: data.fromAccountId,
|
||||
note: data.note,
|
||||
};
|
||||
const response = await api.post<{ success: boolean; data: AllocationResult }>(`/allocation-rules/${id}/apply`, payload);
|
||||
if (!response.data) {
|
||||
throw new Error('Failed to apply allocation rule');
|
||||
}
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get suggested allocation rules for income
|
||||
*/
|
||||
export const suggestAllocationForIncome = async (amount: number, accountId: number): Promise<AllocationRule[]> => {
|
||||
const response = await api.get<{ success: boolean; data: Record<string, unknown>[] }>(`/allocation-rules/suggest?amount=${amount}&account_id=${accountId}`);
|
||||
return (response.data || []).map(transformAllocationRule);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get trigger type label
|
||||
*/
|
||||
export const getTriggerTypeLabel = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'income':
|
||||
return '收入触发';
|
||||
case 'manual':
|
||||
return '手动触发';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get target type label
|
||||
*/
|
||||
export const getTargetTypeLabel = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'account':
|
||||
return '账户';
|
||||
case 'piggy_bank':
|
||||
return '存钱罐';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
187
copy/src/services/api.ts
Normal file
187
copy/src/services/api.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* API Service - HTTP client configuration and base API calls
|
||||
* Feature: api-interface-optimization
|
||||
* Validates: Requirements 12.3, 12.4
|
||||
*/
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
// Token storage keys
|
||||
const ACCESS_TOKEN_KEY = 'access_token';
|
||||
const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
params?: Record<string, string | number | boolean | undefined>;
|
||||
skipAuth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token from storage
|
||||
*/
|
||||
function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh token from storage
|
||||
*/
|
||||
function getRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set tokens in storage
|
||||
*/
|
||||
function setTokens(accessToken: string, refreshToken: string): void {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear tokens from storage
|
||||
*/
|
||||
function clearTokens(): void {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build URL with query parameters
|
||||
*/
|
||||
function buildUrl(
|
||||
endpoint: string,
|
||||
params?: Record<string, string | number | boolean | undefined>
|
||||
): string {
|
||||
const url = new URL(`${API_BASE_URL}${endpoint}`);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
async function refreshAccessToken(): Promise<boolean> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
clearTokens();
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
setTokens(data.data.access_token, data.data.refresh_token);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
clearTokens();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic fetch wrapper with error handling and automatic token refresh
|
||||
* Validates: Requirements 12.3, 12.4
|
||||
*/
|
||||
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { params, skipAuth, ...fetchOptions } = options;
|
||||
const url = buildUrl(endpoint, params);
|
||||
|
||||
const defaultHeaders: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Add Authorization header if token exists and not skipped
|
||||
if (!skipAuth) {
|
||||
const accessToken = getAccessToken();
|
||||
if (accessToken) {
|
||||
(defaultHeaders as Record<string, string>)['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
}
|
||||
|
||||
let response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...fetchOptions.headers,
|
||||
},
|
||||
});
|
||||
|
||||
// If unauthorized, try to refresh token and retry
|
||||
if (response.status === 401 && !skipAuth) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
const newToken = getAccessToken();
|
||||
if (newToken) {
|
||||
(defaultHeaders as Record<string, string>)['Authorization'] = `Bearer ${newToken}`;
|
||||
response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...fetchOptions.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error?.message || errorData.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// Handle 204 No Content - no response body
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
// Check if response has content
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// If no JSON content, return empty object
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP methods
|
||||
*/
|
||||
export const api = {
|
||||
get: <T>(endpoint: string, params?: Record<string, string | number | boolean | undefined>) =>
|
||||
request<T>(endpoint, { method: 'GET', params }),
|
||||
|
||||
post: <T>(endpoint: string, data?: unknown) =>
|
||||
request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
}),
|
||||
|
||||
put: <T>(endpoint: string, data?: unknown) =>
|
||||
request<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
}),
|
||||
|
||||
delete: <T>(endpoint: string) => request<T>(endpoint, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export default api;
|
||||
126
copy/src/services/appLockService.ts
Normal file
126
copy/src/services/appLockService.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* App Lock Service - API calls for application lock management
|
||||
*/
|
||||
|
||||
import api from './api';
|
||||
import type { ApiResponse } from '../types';
|
||||
|
||||
/**
|
||||
* App lock status interface
|
||||
*/
|
||||
export interface AppLockStatus {
|
||||
is_enabled: boolean;
|
||||
is_locked: boolean;
|
||||
failed_attempts: number;
|
||||
locked_until?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set password request interface
|
||||
*/
|
||||
export interface SetPasswordRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify password request interface
|
||||
*/
|
||||
export interface VerifyPasswordRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify password response interface
|
||||
*/
|
||||
export interface VerifyPasswordResponse {
|
||||
valid: boolean;
|
||||
failed_attempts: number;
|
||||
locked_until?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change password request interface
|
||||
*/
|
||||
export interface ChangePasswordRequest {
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable lock request interface
|
||||
*/
|
||||
export interface DisableLockRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get app lock status
|
||||
*/
|
||||
export async function getAppLockStatus(): Promise<AppLockStatus> {
|
||||
const response = await api.get<ApiResponse<AppLockStatus>>('/app-lock/status');
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to get app lock status');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set app lock password
|
||||
*/
|
||||
export async function setAppLockPassword(password: string): Promise<void> {
|
||||
const response = await api.post<ApiResponse<void>>('/app-lock/password', {
|
||||
password,
|
||||
});
|
||||
if (!response.success && response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify app lock password
|
||||
*/
|
||||
export async function verifyAppLockPassword(password: string): Promise<VerifyPasswordResponse> {
|
||||
const response = await api.post<ApiResponse<VerifyPasswordResponse>>('/app-lock/verify', {
|
||||
password,
|
||||
});
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to verify password');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change app lock password
|
||||
*/
|
||||
export async function changeAppLockPassword(
|
||||
oldPassword: string,
|
||||
newPassword: string
|
||||
): Promise<void> {
|
||||
const response = await api.post<ApiResponse<void>>('/app-lock/password/change', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
if (!response.success && response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable app lock
|
||||
*/
|
||||
export async function disableAppLock(password: string): Promise<void> {
|
||||
const response = await api.post<ApiResponse<void>>('/app-lock/disable', {
|
||||
password,
|
||||
});
|
||||
if (!response.success && response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getAppLockStatus,
|
||||
setAppLockPassword,
|
||||
verifyAppLockPassword,
|
||||
changeAppLockPassword,
|
||||
disableAppLock,
|
||||
};
|
||||
179
copy/src/services/authService.ts
Normal file
179
copy/src/services/authService.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Authentication Service - Handles user authentication operations
|
||||
* Feature: api-interface-optimization
|
||||
* Validates: Requirements 12, 13
|
||||
*/
|
||||
|
||||
import api from './api';
|
||||
|
||||
// Token storage keys
|
||||
const ACCESS_TOKEN_KEY = 'access_token';
|
||||
const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||
|
||||
// Types
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
avatar?: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export interface RegisterInput {
|
||||
email: string;
|
||||
password: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface LoginInput {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
user: User;
|
||||
tokens: TokenPair;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RefreshResponse {
|
||||
success: boolean;
|
||||
data: TokenPair;
|
||||
}
|
||||
|
||||
// Token management
|
||||
export function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setTokens(tokens: TokenPair): void {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token);
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getAccessToken();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
* Validates: Requirements 12.1, 12.2
|
||||
*/
|
||||
export async function register(input: RegisterInput): Promise<{ user: User; tokens: TokenPair }> {
|
||||
const response = await api.post<AuthResponse>('/auth/register', input);
|
||||
if (response.success && response.data) {
|
||||
setTokens(response.data.tokens);
|
||||
return response.data;
|
||||
}
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with email and password
|
||||
* Validates: Requirements 12.2
|
||||
*/
|
||||
export async function login(input: LoginInput): Promise<{ user: User; tokens: TokenPair }> {
|
||||
const response = await api.post<AuthResponse>('/auth/login', input);
|
||||
if (response.success && response.data) {
|
||||
setTokens(response.data.tokens);
|
||||
return response.data;
|
||||
}
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
* Validates: Requirements 12.4
|
||||
*/
|
||||
export async function refreshAccessToken(): Promise<TokenPair> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await api.post<RefreshResponse>('/auth/refresh', {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
setTokens(response.data);
|
||||
return response.data;
|
||||
}
|
||||
throw new Error('Token refresh failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout - clear tokens
|
||||
*/
|
||||
export function logout(): void {
|
||||
clearTokens();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GitHub OAuth login URL
|
||||
* Validates: Requirements 13.1
|
||||
*/
|
||||
export function getGitHubLoginUrl(state?: string): string {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1';
|
||||
const url = new URL(`${baseUrl}/auth/github`);
|
||||
if (state) {
|
||||
url.searchParams.append('state', state);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to GitHub OAuth login
|
||||
* Validates: Requirements 13.1
|
||||
*/
|
||||
export function loginWithGitHub(state?: string): void {
|
||||
window.location.href = getGitHubLoginUrl(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle GitHub OAuth callback
|
||||
* Validates: Requirements 13.4, 13.5
|
||||
*/
|
||||
export async function handleGitHubCallback(code: string): Promise<{ user: User; tokens: TokenPair }> {
|
||||
const response = await api.get<AuthResponse>('/auth/github/callback', { code });
|
||||
if (response.success && response.data) {
|
||||
setTokens(response.data.tokens);
|
||||
return response.data;
|
||||
}
|
||||
throw new Error('GitHub authentication failed');
|
||||
}
|
||||
|
||||
export default {
|
||||
register,
|
||||
login,
|
||||
logout,
|
||||
refreshAccessToken,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
setTokens,
|
||||
clearTokens,
|
||||
isAuthenticated,
|
||||
loginWithGitHub,
|
||||
getGitHubLoginUrl,
|
||||
handleGitHubCallback,
|
||||
};
|
||||
92
copy/src/services/backupService.ts
Normal file
92
copy/src/services/backupService.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Backup Service - API calls for backup and restore operations
|
||||
*/
|
||||
|
||||
import api from './api';
|
||||
import type { ApiResponse } from '../types';
|
||||
|
||||
/**
|
||||
* Backup request interface
|
||||
*/
|
||||
export interface BackupRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup response interface
|
||||
*/
|
||||
export interface BackupResponse {
|
||||
file_path: string;
|
||||
checksum: string;
|
||||
created_at: string;
|
||||
size_bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore request interface
|
||||
*/
|
||||
export interface RestoreRequest {
|
||||
file_path: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify backup request interface
|
||||
*/
|
||||
export interface VerifyBackupRequest {
|
||||
file_path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify backup response interface
|
||||
*/
|
||||
export interface VerifyBackupResponse {
|
||||
valid: boolean;
|
||||
checksum: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export database backup
|
||||
*/
|
||||
export async function exportBackup(password: string): Promise<BackupResponse> {
|
||||
const response = await api.post<ApiResponse<BackupResponse>>('/backup/export', {
|
||||
password,
|
||||
});
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to export backup');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import and restore from backup
|
||||
*/
|
||||
export async function importBackup(filePath: string, password: string): Promise<void> {
|
||||
const response = await api.post<ApiResponse<void>>('/backup/import', {
|
||||
file_path: filePath,
|
||||
password,
|
||||
});
|
||||
if (!response.success && response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify backup file integrity
|
||||
*/
|
||||
export async function verifyBackup(filePath: string): Promise<VerifyBackupResponse> {
|
||||
const response = await api.post<ApiResponse<VerifyBackupResponse>>('/backup/verify', {
|
||||
file_path: filePath,
|
||||
});
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to verify backup');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export default {
|
||||
exportBackup,
|
||||
importBackup,
|
||||
verifyBackup,
|
||||
};
|
||||
171
copy/src/services/budgetService.ts
Normal file
171
copy/src/services/budgetService.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Budget Service - API calls for budget management
|
||||
*/
|
||||
|
||||
import api from './api';
|
||||
import type { Budget, ApiResponse, BudgetPeriodType } from '../types';
|
||||
|
||||
/**
|
||||
* Budget form input interface
|
||||
*/
|
||||
export interface BudgetFormInput {
|
||||
name: string;
|
||||
amount: number;
|
||||
periodType: BudgetPeriodType;
|
||||
categoryId?: number;
|
||||
accountId?: number;
|
||||
isRolling: boolean;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Budget progress response interface
|
||||
*/
|
||||
export interface BudgetProgress {
|
||||
budgetId: number;
|
||||
spent: number;
|
||||
remaining: number;
|
||||
progress: number;
|
||||
isWarning: boolean; // >= 80%
|
||||
isOverBudget: boolean; // > 100%
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all budgets
|
||||
*/
|
||||
export async function getBudgets(): Promise<Budget[]> {
|
||||
const response = await api.get<ApiResponse<Budget[]>>('/budgets');
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single budget by ID
|
||||
*/
|
||||
export async function getBudget(id: number): Promise<Budget> {
|
||||
const response = await api.get<ApiResponse<Budget>>(`/budgets/${id}`);
|
||||
if (!response.data) {
|
||||
throw new Error('Budget not found');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get budget progress
|
||||
*/
|
||||
export async function getBudgetProgress(id: number): Promise<BudgetProgress> {
|
||||
const response = await api.get<ApiResponse<BudgetProgress>>(`/budgets/${id}/progress`);
|
||||
if (!response.data) {
|
||||
throw new Error('Budget progress not found');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new budget
|
||||
*/
|
||||
export async function createBudget(data: BudgetFormInput): Promise<Budget> {
|
||||
// Convert camelCase to snake_case for backend
|
||||
// Convert date strings to RFC3339 format (YYYY-MM-DDTHH:MM:SSZ)
|
||||
const payload = {
|
||||
name: data.name,
|
||||
amount: data.amount,
|
||||
period_type: data.periodType,
|
||||
category_id: data.categoryId,
|
||||
account_id: data.accountId,
|
||||
is_rolling: data.isRolling,
|
||||
start_date: data.startDate ? `${data.startDate}T00:00:00Z` : undefined,
|
||||
end_date: data.endDate ? `${data.endDate}T23:59:59Z` : undefined,
|
||||
};
|
||||
const response = await api.post<ApiResponse<Budget>>('/budgets', payload);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to create budget');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing budget
|
||||
*/
|
||||
export async function updateBudget(id: number, data: Partial<BudgetFormInput>): Promise<Budget> {
|
||||
// Convert camelCase to snake_case for backend
|
||||
// Convert date strings to RFC3339 format (YYYY-MM-DDTHH:MM:SSZ)
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) payload.name = data.name;
|
||||
if (data.amount !== undefined) payload.amount = data.amount;
|
||||
if (data.periodType !== undefined) payload.period_type = data.periodType;
|
||||
if (data.categoryId !== undefined) payload.category_id = data.categoryId;
|
||||
if (data.accountId !== undefined) payload.account_id = data.accountId;
|
||||
if (data.isRolling !== undefined) payload.is_rolling = data.isRolling;
|
||||
if (data.startDate !== undefined) payload.start_date = `${data.startDate}T00:00:00Z`;
|
||||
if (data.endDate !== undefined) payload.end_date = data.endDate ? `${data.endDate}T23:59:59Z` : null;
|
||||
|
||||
const response = await api.put<ApiResponse<Budget>>(`/budgets/${id}`, payload);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to update budget');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a budget
|
||||
*/
|
||||
export async function deleteBudget(id: number): Promise<void> {
|
||||
await api.delete<ApiResponse<void>>(`/budgets/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all budget progress
|
||||
* Returns progress for all active budgets including warning flags
|
||||
*/
|
||||
export async function getAllBudgetProgress(): Promise<BudgetProgress[]> {
|
||||
const response = await api.get<ApiResponse<BudgetProgress[]>>('/budgets/progress');
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate budget status
|
||||
*/
|
||||
export function calculateBudgetStatus(budget: Budget): {
|
||||
isWarning: boolean;
|
||||
isOverBudget: boolean;
|
||||
statusText: string;
|
||||
} {
|
||||
const progress = budget.progress || 0;
|
||||
const isWarning = progress >= 80 && progress < 100;
|
||||
const isOverBudget = progress >= 100;
|
||||
|
||||
let statusText = '正常';
|
||||
if (isOverBudget) {
|
||||
statusText = '超支';
|
||||
} else if (isWarning) {
|
||||
statusText = '预警';
|
||||
}
|
||||
|
||||
return { isWarning, isOverBudget, statusText };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get period type label in Chinese
|
||||
*/
|
||||
export function getPeriodTypeLabel(periodType: BudgetPeriodType): string {
|
||||
const labels: Record<BudgetPeriodType, string> = {
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
yearly: '每年',
|
||||
};
|
||||
return labels[periodType] || periodType;
|
||||
}
|
||||
|
||||
export default {
|
||||
getBudgets,
|
||||
getBudget,
|
||||
getBudgetProgress,
|
||||
getAllBudgetProgress,
|
||||
createBudget,
|
||||
updateBudget,
|
||||
deleteBudget,
|
||||
calculateBudgetStatus,
|
||||
getPeriodTypeLabel,
|
||||
};
|
||||
119
copy/src/services/categoryService.ts
Normal file
119
copy/src/services/categoryService.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Category Service - API calls for category management
|
||||
*/
|
||||
|
||||
import api from './api';
|
||||
import type { Category, ApiResponse, TransactionType } from '../types';
|
||||
|
||||
export interface CategoryFormInput {
|
||||
name: string;
|
||||
icon: string;
|
||||
type: TransactionType;
|
||||
parentId?: number;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories (flat list)
|
||||
*/
|
||||
export async function getCategories(type?: TransactionType): Promise<Category[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (type) {
|
||||
params.type = type;
|
||||
}
|
||||
const response = await api.get<ApiResponse<Category[]>>('/categories', params);
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories as hierarchical tree
|
||||
*/
|
||||
export async function getCategoryTree(type?: TransactionType): Promise<Category[]> {
|
||||
const params: Record<string, string> = { tree: 'true' };
|
||||
if (type) {
|
||||
params.type = type;
|
||||
}
|
||||
const response = await api.get<ApiResponse<Category[]>>('/categories', params);
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single category by ID
|
||||
*/
|
||||
export async function getCategory(id: number, withChildren = false): Promise<Category> {
|
||||
const params: Record<string, string> = {};
|
||||
if (withChildren) {
|
||||
params.children = 'true';
|
||||
}
|
||||
const response = await api.get<ApiResponse<Category>>(`/categories/${id}`, params);
|
||||
if (!response.data) {
|
||||
throw new Error('Category not found');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get child categories of a parent
|
||||
*/
|
||||
export async function getChildCategories(parentId: number): Promise<Category[]> {
|
||||
const response = await api.get<ApiResponse<Category[]>>(`/categories/${parentId}/children`);
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new category
|
||||
*/
|
||||
export async function createCategory(data: CategoryFormInput): Promise<Category> {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const payload = {
|
||||
name: data.name,
|
||||
icon: data.icon,
|
||||
type: data.type,
|
||||
parent_id: data.parentId,
|
||||
sort_order: data.sortOrder,
|
||||
};
|
||||
const response = await api.post<ApiResponse<Category>>('/categories', payload);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to create category');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing category
|
||||
*/
|
||||
export async function updateCategory(
|
||||
id: number,
|
||||
data: Partial<CategoryFormInput>
|
||||
): Promise<Category> {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) payload.name = data.name;
|
||||
if (data.icon !== undefined) payload.icon = data.icon;
|
||||
if (data.type !== undefined) payload.type = data.type;
|
||||
if (data.parentId !== undefined) payload.parent_id = data.parentId;
|
||||
if (data.sortOrder !== undefined) payload.sort_order = data.sortOrder;
|
||||
|
||||
const response = await api.put<ApiResponse<Category>>(`/categories/${id}`, payload);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to update category');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a category
|
||||
*/
|
||||
export async function deleteCategory(id: number): Promise<void> {
|
||||
await api.delete<ApiResponse<void>>(`/categories/${id}`);
|
||||
}
|
||||
|
||||
export default {
|
||||
getCategories,
|
||||
getCategoryTree,
|
||||
getCategory,
|
||||
getChildCategories,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
};
|
||||
404
copy/src/services/exchangeRateService.ts
Normal file
404
copy/src/services/exchangeRateService.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Exchange Rate Service - API calls for exchange rate management
|
||||
* Updated to support the new CNY-based exchange rate system with Redis caching
|
||||
*/
|
||||
|
||||
import api from './api';
|
||||
import type { ApiResponse } from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// New Types for the redesigned exchange rate system
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Exchange rate data transfer object from the new API
|
||||
*/
|
||||
export interface ExchangeRateDTO {
|
||||
currency: string;
|
||||
currency_name: string;
|
||||
symbol: string;
|
||||
rate: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync status information
|
||||
*/
|
||||
export interface SyncStatus {
|
||||
last_sync_time: string;
|
||||
last_sync_status: 'success' | 'failed';
|
||||
next_sync_time: string;
|
||||
rates_count: number;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from GET /api/v1/exchange-rates
|
||||
*/
|
||||
export interface AllRatesResponse {
|
||||
rates: ExchangeRateDTO[];
|
||||
base_currency: string;
|
||||
sync_status?: SyncStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for currency conversion (new format)
|
||||
*/
|
||||
export interface ConvertCurrencyInput {
|
||||
amount: number;
|
||||
from_currency: string;
|
||||
to_currency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Currency conversion result (new format)
|
||||
*/
|
||||
export interface ConversionResultDTO {
|
||||
original_amount: number;
|
||||
from_currency: string;
|
||||
to_currency: string;
|
||||
converted_amount: number;
|
||||
rate_used: number;
|
||||
converted_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync result from manual refresh
|
||||
*/
|
||||
export interface SyncResultDTO {
|
||||
message: string;
|
||||
rates_updated: number;
|
||||
sync_time: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Currency information for display
|
||||
*/
|
||||
export interface CurrencyInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
flag: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Legacy types for backward compatibility
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @deprecated Use ConvertCurrencyInput instead
|
||||
*/
|
||||
export interface LegacyConvertCurrencyInput {
|
||||
amount: number;
|
||||
from_currency: string;
|
||||
to_currency: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use ConversionResultDTO instead
|
||||
*/
|
||||
export interface LegacyConversionResult {
|
||||
original_amount: number;
|
||||
from_currency: string;
|
||||
to_currency: string;
|
||||
converted_amount: number;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Legacy Types for backward compatibility with old API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Old API exchange rate format (from_currency/to_currency pairs)
|
||||
*/
|
||||
interface LegacyExchangeRate {
|
||||
id: number;
|
||||
from_currency: string;
|
||||
to_currency: string;
|
||||
rate: number;
|
||||
effective_date: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all exchange rates with sync status
|
||||
* Supports both old API format (array) and new API format (object with rates)
|
||||
* New API: GET /api/v1/exchange-rates
|
||||
* @param currencies - Optional array of currency codes to fetch (batch query)
|
||||
*/
|
||||
export async function getExchangeRates(currencies?: string[]): Promise<AllRatesResponse> {
|
||||
// Build query string for batch query
|
||||
const queryParams = currencies && currencies.length > 0
|
||||
? `?currencies=${currencies.join(',')}`
|
||||
: '';
|
||||
|
||||
const response = await api.get<ApiResponse<AllRatesResponse | LegacyExchangeRate[]>>(
|
||||
`/exchange-rates${queryParams}`
|
||||
);
|
||||
|
||||
if (!response.data) {
|
||||
return {
|
||||
rates: [],
|
||||
base_currency: 'CNY',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if response is in old format (array) or new format (object with rates)
|
||||
if (Array.isArray(response.data)) {
|
||||
// Old API format: transform to new format
|
||||
// Old API returns rates like: { from_currency: "EUR", to_currency: "CNY", rate: 8.2027 }
|
||||
// This means 1 EUR = 8.2027 CNY
|
||||
// We need to display as: 1 CNY = X foreign currency
|
||||
const legacyRates = response.data as LegacyExchangeRate[];
|
||||
const transformedRates: ExchangeRateDTO[] = legacyRates
|
||||
.filter(rate => rate.from_currency === 'CNY' || rate.to_currency === 'CNY')
|
||||
.map(rate => {
|
||||
// Determine the target currency (the one that's not CNY)
|
||||
const currency = rate.to_currency === 'CNY' ? rate.from_currency : rate.to_currency;
|
||||
// Old API: from_currency -> to_currency at rate
|
||||
// If to_currency is CNY: 1 foreign = rate CNY, so 1 CNY = 1/rate foreign
|
||||
// If from_currency is CNY: 1 CNY = rate foreign (already correct)
|
||||
const normalizedRate = rate.to_currency === 'CNY' ? 1 / rate.rate : rate.rate;
|
||||
const currencyInfo = getCurrencyInfo(currency);
|
||||
|
||||
return {
|
||||
currency,
|
||||
currency_name: currencyInfo?.name || currency,
|
||||
symbol: currencyInfo?.symbol || currency,
|
||||
rate: normalizedRate,
|
||||
updated_at: rate.effective_date,
|
||||
};
|
||||
});
|
||||
|
||||
// Remove duplicates (keep the most recent by effective_date)
|
||||
const uniqueRates = new Map<string, ExchangeRateDTO>();
|
||||
transformedRates.forEach(rate => {
|
||||
const existing = uniqueRates.get(rate.currency);
|
||||
if (!existing || rate.updated_at > existing.updated_at) {
|
||||
uniqueRates.set(rate.currency, rate);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
rates: Array.from(uniqueRates.values()),
|
||||
base_currency: 'CNY',
|
||||
};
|
||||
}
|
||||
|
||||
// New API format: return as-is
|
||||
return response.data as AllRatesResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get exchange rate for a specific currency
|
||||
* New API: GET /api/v1/exchange-rates/:currency
|
||||
* @param currency - Currency code (e.g., 'USD', 'EUR')
|
||||
*/
|
||||
export async function getRate(currency: string): Promise<ExchangeRateDTO> {
|
||||
const response = await api.get<ApiResponse<ExchangeRateDTO>>(`/exchange-rates/${currency}`);
|
||||
if (!response.data) {
|
||||
throw new Error(`Exchange rate not found for currency: ${currency}`);
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert currency amount
|
||||
* New API: POST /api/v1/exchange-rates/convert
|
||||
*/
|
||||
export async function convertCurrency(input: ConvertCurrencyInput): Promise<ConversionResultDTO> {
|
||||
const response = await api.post<ApiResponse<ConversionResultDTO>>(
|
||||
'/exchange-rates/convert',
|
||||
input
|
||||
);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to convert currency');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually refresh exchange rates from YunAPI
|
||||
* New API: POST /api/v1/exchange-rates/refresh
|
||||
* Supports both old and new API response formats
|
||||
*/
|
||||
export async function refreshExchangeRates(): Promise<SyncResultDTO> {
|
||||
const response = await api.post<ApiResponse<SyncResultDTO | { message: string; rates_saved?: number }>>('/exchange-rates/refresh');
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to refresh exchange rates');
|
||||
}
|
||||
|
||||
// Check if response is in old format
|
||||
const data = response.data as { message?: string; rates_saved?: number; rates_updated?: number; sync_time?: string };
|
||||
if ('rates_saved' in data && !('rates_updated' in data)) {
|
||||
// Old API format
|
||||
return {
|
||||
message: data.message || 'Exchange rates refreshed',
|
||||
rates_updated: data.rates_saved || 0,
|
||||
sync_time: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return response.data as SyncResultDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync status
|
||||
* New API: GET /api/v1/exchange-rates/sync-status
|
||||
* Returns null if the endpoint is not available (old API)
|
||||
*/
|
||||
export async function getSyncStatus(): Promise<SyncStatus | null> {
|
||||
try {
|
||||
const response = await api.get<ApiResponse<SyncStatus>>('/exchange-rates/sync-status');
|
||||
if (!response.data) {
|
||||
return null;
|
||||
}
|
||||
return response.data;
|
||||
} catch {
|
||||
// Old API doesn't have this endpoint
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for exchange rate service
|
||||
* New API: GET /api/v1/exchange-rates/health
|
||||
* Returns null if the endpoint is not available
|
||||
*/
|
||||
export async function getHealthStatus(): Promise<{
|
||||
status: string;
|
||||
cache_status?: string;
|
||||
sync_status?: string;
|
||||
last_sync?: string;
|
||||
rates_count?: number;
|
||||
api_url?: string;
|
||||
} | null> {
|
||||
try {
|
||||
const response = await api.get<ApiResponse<any>>('/exchange-rates/health');
|
||||
if (!response.data) {
|
||||
return null;
|
||||
}
|
||||
return response.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Currency Information - Extended to support all 37 YunAPI currencies
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all supported currencies with their display information
|
||||
* Extended to include all 37 currencies from YunAPI
|
||||
*/
|
||||
export function getSupportedCurrencies(): CurrencyInfo[] {
|
||||
return [
|
||||
// Major currencies (6)
|
||||
{ code: 'CNY', name: '人民币', symbol: '¥', flag: 'https://flagcdn.com/w40/cn.png' },
|
||||
{ code: 'USD', name: '美元', symbol: '$', flag: 'https://flagcdn.com/w40/us.png' },
|
||||
{ code: 'EUR', name: '欧元', symbol: '€', flag: 'https://flagcdn.com/w40/eu.png' },
|
||||
{ code: 'JPY', name: '日元', symbol: '¥', flag: 'https://flagcdn.com/w40/jp.png' },
|
||||
{ code: 'GBP', name: '英镑', symbol: '£', flag: 'https://flagcdn.com/w40/gb.png' },
|
||||
{ code: 'HKD', name: '港币', symbol: 'HK$', flag: 'https://flagcdn.com/w40/hk.png' },
|
||||
|
||||
// Asia Pacific (16)
|
||||
{ code: 'AUD', name: '澳元', symbol: 'A$', flag: 'https://flagcdn.com/w40/au.png' },
|
||||
{ code: 'NZD', name: '新西兰元', symbol: 'NZ$', flag: 'https://flagcdn.com/w40/nz.png' },
|
||||
{ code: 'SGD', name: '新加坡元', symbol: 'S$', flag: 'https://flagcdn.com/w40/sg.png' },
|
||||
{ code: 'KRW', name: '韩元', symbol: '₩', flag: 'https://flagcdn.com/w40/kr.png' },
|
||||
{ code: 'THB', name: '泰铢', symbol: '฿', flag: 'https://flagcdn.com/w40/th.png' },
|
||||
{ code: 'TWD', name: '新台币', symbol: 'NT$', flag: 'https://flagcdn.com/w40/tw.png' },
|
||||
{ code: 'MOP', name: '澳门元', symbol: 'MOP$', flag: 'https://flagcdn.com/w40/mo.png' },
|
||||
{ code: 'PHP', name: '菲律宾比索', symbol: '₱', flag: 'https://flagcdn.com/w40/ph.png' },
|
||||
{ code: 'IDR', name: '印尼盾', symbol: 'Rp', flag: 'https://flagcdn.com/w40/id.png' },
|
||||
{ code: 'INR', name: '印度卢比', symbol: '₹', flag: 'https://flagcdn.com/w40/in.png' },
|
||||
{ code: 'VND', name: '越南盾', symbol: '₫', flag: 'https://flagcdn.com/w40/vn.png' },
|
||||
{ code: 'MNT', name: '蒙古图格里克', symbol: '₮', flag: 'https://flagcdn.com/w40/mn.png' },
|
||||
{ code: 'KHR', name: '柬埔寨瑞尔', symbol: '៛', flag: 'https://flagcdn.com/w40/kh.png' },
|
||||
{ code: 'NPR', name: '尼泊尔卢比', symbol: '₨', flag: 'https://flagcdn.com/w40/np.png' },
|
||||
{ code: 'PKR', name: '巴基斯坦卢比', symbol: '₨', flag: 'https://flagcdn.com/w40/pk.png' },
|
||||
{ code: 'BND', name: '文莱元', symbol: 'B$', flag: 'https://flagcdn.com/w40/bn.png' },
|
||||
|
||||
// Europe (8)
|
||||
{ code: 'CHF', name: '瑞士法郎', symbol: 'CHF', flag: 'https://flagcdn.com/w40/ch.png' },
|
||||
{ code: 'SEK', name: '瑞典克朗', symbol: 'kr', flag: 'https://flagcdn.com/w40/se.png' },
|
||||
{ code: 'NOK', name: '挪威克朗', symbol: 'kr', flag: 'https://flagcdn.com/w40/no.png' },
|
||||
{ code: 'DKK', name: '丹麦克朗', symbol: 'kr', flag: 'https://flagcdn.com/w40/dk.png' },
|
||||
{ code: 'CZK', name: '捷克克朗', symbol: 'Kč', flag: 'https://flagcdn.com/w40/cz.png' },
|
||||
{ code: 'HUF', name: '匈牙利福林', symbol: 'Ft', flag: 'https://flagcdn.com/w40/hu.png' },
|
||||
{ code: 'RUB', name: '俄罗斯卢布', symbol: '₽', flag: 'https://flagcdn.com/w40/ru.png' },
|
||||
{ code: 'TRY', name: '土耳其里拉', symbol: '₺', flag: 'https://flagcdn.com/w40/tr.png' },
|
||||
|
||||
// Americas (3)
|
||||
{ code: 'CAD', name: '加元', symbol: 'C$', flag: 'https://flagcdn.com/w40/ca.png' },
|
||||
{ code: 'MXN', name: '墨西哥比索', symbol: 'Mex$', flag: 'https://flagcdn.com/w40/mx.png' },
|
||||
{ code: 'BRL', name: '巴西雷亚尔', symbol: 'R$', flag: 'https://flagcdn.com/w40/br.png' },
|
||||
|
||||
// Middle East & Africa (6)
|
||||
{ code: 'AED', name: '阿联酋迪拉姆', symbol: 'د.إ', flag: 'https://flagcdn.com/w40/ae.png' },
|
||||
{ code: 'SAR', name: '沙特里亚尔', symbol: '﷼', flag: 'https://flagcdn.com/w40/sa.png' },
|
||||
{ code: 'QAR', name: '卡塔尔里亚尔', symbol: '﷼', flag: 'https://flagcdn.com/w40/qa.png' },
|
||||
{ code: 'KWD', name: '科威特第纳尔', symbol: 'د.ك', flag: 'https://flagcdn.com/w40/kw.png' },
|
||||
{ code: 'ILS', name: '以色列新谢克尔', symbol: '₪', flag: 'https://flagcdn.com/w40/il.png' },
|
||||
{ code: 'ZAR', name: '南非兰特', symbol: 'R', flag: 'https://flagcdn.com/w40/za.png' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currency info by code
|
||||
*/
|
||||
export function getCurrencyInfo(code: string): CurrencyInfo | undefined {
|
||||
return getSupportedCurrencies().find((c) => c.code === code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency amount with symbol
|
||||
*/
|
||||
export function formatCurrency(amount: number, currency: string): string {
|
||||
const info = getCurrencyInfo(currency);
|
||||
const symbol = info?.symbol || currency;
|
||||
return `${symbol}${amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currency name by code
|
||||
*/
|
||||
export function getCurrencyName(code: string): string {
|
||||
const info = getCurrencyInfo(code);
|
||||
return info?.name || code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currency symbol by code
|
||||
*/
|
||||
export function getCurrencySymbol(code: string): string {
|
||||
const info = getCurrencyInfo(code);
|
||||
return info?.symbol || code;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Export
|
||||
// ============================================================================
|
||||
|
||||
export default {
|
||||
// New API methods
|
||||
getExchangeRates,
|
||||
getRate,
|
||||
convertCurrency,
|
||||
refreshExchangeRates,
|
||||
getSyncStatus,
|
||||
getHealthStatus,
|
||||
|
||||
// Currency utilities
|
||||
getSupportedCurrencies,
|
||||
getCurrencyInfo,
|
||||
formatCurrency,
|
||||
getCurrencyName,
|
||||
getCurrencySymbol,
|
||||
};
|
||||
218
copy/src/services/piggyBankService.ts
Normal file
218
copy/src/services/piggyBankService.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Piggy Bank Service - API calls for piggy bank (savings goals) management
|
||||
*/
|
||||
|
||||
import api from './api';
|
||||
import type { PiggyBank, ApiResponse, PiggyBankType } from '../types';
|
||||
|
||||
/**
|
||||
* Piggy Bank form input interface
|
||||
*/
|
||||
export interface PiggyBankFormInput {
|
||||
name: string;
|
||||
targetAmount: number;
|
||||
type: PiggyBankType;
|
||||
targetDate?: string;
|
||||
linkedAccountId?: number;
|
||||
autoRule?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deposit/Withdraw request interface
|
||||
*/
|
||||
export interface PiggyBankTransactionInput {
|
||||
amount: number;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all piggy banks
|
||||
*/
|
||||
export async function getPiggyBanks(): Promise<PiggyBank[]> {
|
||||
const response = await api.get<ApiResponse<PiggyBank[]>>('/piggy-banks');
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single piggy bank by ID
|
||||
*/
|
||||
export async function getPiggyBank(id: number): Promise<PiggyBank> {
|
||||
const response = await api.get<ApiResponse<PiggyBank>>(`/piggy-banks/${id}`);
|
||||
if (!response.data) {
|
||||
throw new Error('Piggy bank not found');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new piggy bank
|
||||
*/
|
||||
export async function createPiggyBank(data: PiggyBankFormInput): Promise<PiggyBank> {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const payload = {
|
||||
name: data.name,
|
||||
target_amount: data.targetAmount,
|
||||
type: data.type,
|
||||
target_date: data.targetDate,
|
||||
linked_account_id: data.linkedAccountId,
|
||||
auto_rule: data.autoRule,
|
||||
};
|
||||
const response = await api.post<ApiResponse<PiggyBank>>('/piggy-banks', payload);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to create piggy bank');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing piggy bank
|
||||
*/
|
||||
export async function updatePiggyBank(
|
||||
id: number,
|
||||
data: Partial<PiggyBankFormInput>
|
||||
): Promise<PiggyBank> {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) payload.name = data.name;
|
||||
if (data.targetAmount !== undefined) payload.target_amount = data.targetAmount;
|
||||
if (data.type !== undefined) payload.type = data.type;
|
||||
if (data.targetDate !== undefined) payload.target_date = data.targetDate;
|
||||
if (data.linkedAccountId !== undefined) payload.linked_account_id = data.linkedAccountId;
|
||||
if (data.autoRule !== undefined) payload.auto_rule = data.autoRule;
|
||||
|
||||
const response = await api.put<ApiResponse<PiggyBank>>(`/piggy-banks/${id}`, payload);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to update piggy bank');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a piggy bank
|
||||
*/
|
||||
export async function deletePiggyBank(id: number): Promise<void> {
|
||||
await api.delete<ApiResponse<void>>(`/piggy-banks/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deposit money into a piggy bank
|
||||
*/
|
||||
export async function depositToPiggyBank(
|
||||
id: number,
|
||||
data: PiggyBankTransactionInput
|
||||
): Promise<PiggyBank> {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const payload = {
|
||||
amount: data.amount,
|
||||
note: data.note,
|
||||
};
|
||||
const response = await api.post<ApiResponse<PiggyBank>>(`/piggy-banks/${id}/deposit`, payload);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to deposit to piggy bank');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw money from a piggy bank
|
||||
*/
|
||||
export async function withdrawFromPiggyBank(
|
||||
id: number,
|
||||
data: PiggyBankTransactionInput
|
||||
): Promise<PiggyBank> {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const payload = {
|
||||
amount: data.amount,
|
||||
note: data.note,
|
||||
};
|
||||
const response = await api.post<ApiResponse<PiggyBank>>(`/piggy-banks/${id}/withdraw`, payload);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to withdraw from piggy bank');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Piggy Bank progress response interface
|
||||
*/
|
||||
export interface PiggyBankProgress {
|
||||
piggyBankId: number;
|
||||
currentAmount: number;
|
||||
targetAmount: number;
|
||||
progress: number;
|
||||
daysRemaining?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all piggy bank progress
|
||||
* Returns progress for all piggy banks
|
||||
*/
|
||||
export async function getAllPiggyBankProgress(): Promise<PiggyBankProgress[]> {
|
||||
const response = await api.get<ApiResponse<PiggyBankProgress[]>>('/piggy-banks/progress');
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get piggy bank type label in Chinese
|
||||
*/
|
||||
export function getPiggyBankTypeLabel(type: PiggyBankType): string {
|
||||
const labels: Record<PiggyBankType, string> = {
|
||||
manual: '手动存钱罐',
|
||||
auto: '自动存钱罐',
|
||||
fixed_deposit: '零存整取',
|
||||
week_52: '52周存钱法',
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate days remaining until target date
|
||||
*/
|
||||
export function calculateDaysRemaining(targetDate?: string): number | null {
|
||||
if (!targetDate) return null;
|
||||
const target = new Date(targetDate);
|
||||
const now = new Date();
|
||||
const diffTime = target.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return diffDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate estimated completion date based on current progress
|
||||
*/
|
||||
export function estimateCompletionDate(
|
||||
currentAmount: number,
|
||||
targetAmount: number,
|
||||
createdAt: string
|
||||
): string | null {
|
||||
if (currentAmount >= targetAmount) return null;
|
||||
if (currentAmount === 0) return null;
|
||||
|
||||
const created = new Date(createdAt);
|
||||
const now = new Date();
|
||||
const daysElapsed = Math.max(
|
||||
1,
|
||||
Math.ceil((now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24))
|
||||
);
|
||||
const dailyRate = currentAmount / daysElapsed;
|
||||
const remainingAmount = targetAmount - currentAmount;
|
||||
const daysRemaining = Math.ceil(remainingAmount / dailyRate);
|
||||
|
||||
const estimatedDate = new Date(now);
|
||||
estimatedDate.setDate(estimatedDate.getDate() + daysRemaining);
|
||||
return estimatedDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
export default {
|
||||
getPiggyBanks,
|
||||
getPiggyBank,
|
||||
createPiggyBank,
|
||||
updatePiggyBank,
|
||||
deletePiggyBank,
|
||||
depositToPiggyBank,
|
||||
withdrawFromPiggyBank,
|
||||
getAllPiggyBankProgress,
|
||||
getPiggyBankTypeLabel,
|
||||
calculateDaysRemaining,
|
||||
estimateCompletionDate,
|
||||
};
|
||||
225
copy/src/services/recurringTransactionService.ts
Normal file
225
copy/src/services/recurringTransactionService.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Recurring Transaction Service - API calls for recurring transaction management
|
||||
* Implements requirements 1.2.1 (create recurring transactions), 1.2.3 (edit recurring transactions)
|
||||
*/
|
||||
|
||||
import api from './api';
|
||||
import type {
|
||||
RecurringTransaction,
|
||||
ApiResponse,
|
||||
TransactionType,
|
||||
FrequencyType,
|
||||
CurrencyCode,
|
||||
Transaction,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Recurring transaction form input
|
||||
*/
|
||||
export interface RecurringTransactionFormInput {
|
||||
amount: number;
|
||||
type: TransactionType;
|
||||
categoryId: number;
|
||||
accountId: number;
|
||||
currency: CurrencyCode;
|
||||
note?: string;
|
||||
frequency: FrequencyType;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process recurring transactions response (from backend, snake_case)
|
||||
*/
|
||||
interface ProcessRecurringTransactionsBackendResponse {
|
||||
processed_count: number;
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process recurring transactions response (frontend, camelCase)
|
||||
*/
|
||||
export interface ProcessRecurringTransactionsResponse {
|
||||
processedCount: number;
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recurring transactions
|
||||
* @param activeOnly - If true, only return active recurring transactions
|
||||
*/
|
||||
export async function getRecurringTransactions(
|
||||
activeOnly?: boolean
|
||||
): Promise<RecurringTransaction[]> {
|
||||
const params: Record<string, string | number | boolean | undefined> = {};
|
||||
if (activeOnly !== undefined) {
|
||||
params.active = activeOnly;
|
||||
}
|
||||
|
||||
const response = await api.get<ApiResponse<RecurringTransaction[]>>(
|
||||
'/recurring-transactions',
|
||||
params
|
||||
);
|
||||
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single recurring transaction by ID
|
||||
*/
|
||||
export async function getRecurringTransaction(id: number): Promise<RecurringTransaction> {
|
||||
const response = await api.get<ApiResponse<RecurringTransaction>>(
|
||||
`/recurring-transactions/${id}`
|
||||
);
|
||||
if (!response.data) {
|
||||
throw new Error('Recurring transaction not found');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new recurring transaction
|
||||
* Validates: Requirements 1.2.1 (创建周期性交易并保存周期规则)
|
||||
*/
|
||||
export async function createRecurringTransaction(
|
||||
data: RecurringTransactionFormInput
|
||||
): Promise<RecurringTransaction> {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const payload = {
|
||||
amount: data.amount,
|
||||
type: data.type,
|
||||
category_id: data.categoryId,
|
||||
account_id: data.accountId,
|
||||
currency: data.currency,
|
||||
note: data.note,
|
||||
frequency: data.frequency,
|
||||
start_date: data.startDate,
|
||||
end_date: data.endDate,
|
||||
};
|
||||
const response = await api.post<ApiResponse<RecurringTransaction>>(
|
||||
'/recurring-transactions',
|
||||
payload
|
||||
);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to create recurring transaction');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing recurring transaction
|
||||
* Validates: Requirements 1.2.3 (编辑周期性交易模板)
|
||||
*/
|
||||
export async function updateRecurringTransaction(
|
||||
id: number,
|
||||
data: Partial<RecurringTransactionFormInput> & { isActive?: boolean }
|
||||
): Promise<RecurringTransaction> {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (data.amount !== undefined) payload.amount = data.amount;
|
||||
if (data.type !== undefined) payload.type = data.type;
|
||||
if (data.categoryId !== undefined) payload.category_id = data.categoryId;
|
||||
if (data.accountId !== undefined) payload.account_id = data.accountId;
|
||||
if (data.currency !== undefined) payload.currency = data.currency;
|
||||
if (data.note !== undefined) payload.note = data.note;
|
||||
if (data.frequency !== undefined) payload.frequency = data.frequency;
|
||||
if (data.startDate !== undefined) payload.start_date = data.startDate;
|
||||
if (data.endDate !== undefined) payload.end_date = data.endDate;
|
||||
if (data.isActive !== undefined) payload.is_active = data.isActive;
|
||||
|
||||
const response = await api.put<ApiResponse<RecurringTransaction>>(
|
||||
`/recurring-transactions/${id}`,
|
||||
payload
|
||||
);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to update recurring transaction');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a recurring transaction
|
||||
* Validates: Requirements 1.2.4 (删除周期性交易)
|
||||
*/
|
||||
export async function deleteRecurringTransaction(id: number): Promise<void> {
|
||||
await api.delete<ApiResponse<void>>(`/recurring-transactions/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process due recurring transactions
|
||||
* Validates: Requirements 1.2.2 (到达周期触发时间自动生成交易记录)
|
||||
* @param time - Optional time parameter for testing (YYYY-MM-DD format)
|
||||
*/
|
||||
export async function processRecurringTransactions(
|
||||
time?: string
|
||||
): Promise<ProcessRecurringTransactionsResponse> {
|
||||
const params: Record<string, string | number | boolean | undefined> = {};
|
||||
if (time) {
|
||||
params.time = time;
|
||||
}
|
||||
|
||||
const response = await api.post<ApiResponse<ProcessRecurringTransactionsBackendResponse>>(
|
||||
'/recurring-transactions/process',
|
||||
undefined
|
||||
);
|
||||
|
||||
// Convert snake_case to camelCase
|
||||
const data = response.data;
|
||||
return {
|
||||
processedCount: data?.processed_count || 0,
|
||||
transactions: data?.transactions || [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get frequency display name in Chinese
|
||||
*/
|
||||
export function getFrequencyDisplayName(frequency: FrequencyType): string {
|
||||
const names: Record<FrequencyType, string> = {
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
yearly: '每年',
|
||||
};
|
||||
return names[frequency] || frequency;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next occurrence date based on frequency
|
||||
* This is a client-side helper for preview purposes
|
||||
*/
|
||||
export function calculateNextOccurrence(
|
||||
startDate: string,
|
||||
frequency: FrequencyType,
|
||||
occurrences: number = 1
|
||||
): string {
|
||||
const date = new Date(startDate);
|
||||
|
||||
switch (frequency) {
|
||||
case 'daily':
|
||||
date.setDate(date.getDate() + occurrences);
|
||||
break;
|
||||
case 'weekly':
|
||||
date.setDate(date.getDate() + 7 * occurrences);
|
||||
break;
|
||||
case 'monthly':
|
||||
date.setMonth(date.getMonth() + occurrences);
|
||||
break;
|
||||
case 'yearly':
|
||||
date.setFullYear(date.getFullYear() + occurrences);
|
||||
break;
|
||||
}
|
||||
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
export default {
|
||||
getRecurringTransactions,
|
||||
getRecurringTransaction,
|
||||
createRecurringTransaction,
|
||||
updateRecurringTransaction,
|
||||
deleteRecurringTransaction,
|
||||
processRecurringTransactions,
|
||||
getFrequencyDisplayName,
|
||||
calculateNextOccurrence,
|
||||
};
|
||||
185
copy/src/services/reportService.ts
Normal file
185
copy/src/services/reportService.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Report Service
|
||||
* Handles API calls for statistical reports and analysis
|
||||
*/
|
||||
|
||||
import api from './api';
|
||||
import type { CurrencyCode } from '../types';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
// Report API Response Types
|
||||
export interface CurrencySummary {
|
||||
currency: CurrencyCode;
|
||||
total_income: number;
|
||||
total_expense: number;
|
||||
balance: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface UnifiedSummary {
|
||||
target_currency: CurrencyCode;
|
||||
total_income: number;
|
||||
total_expense: number;
|
||||
balance: number;
|
||||
conversion_date: string;
|
||||
}
|
||||
|
||||
export interface TransactionSummaryResponse {
|
||||
by_currency: CurrencySummary[];
|
||||
unified?: UnifiedSummary;
|
||||
}
|
||||
|
||||
export interface CategorySummaryItem {
|
||||
category_id: number;
|
||||
category_name: string;
|
||||
currency?: CurrencyCode;
|
||||
total_amount: number;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface CategorySummaryResponse {
|
||||
by_currency: CategorySummaryItem[];
|
||||
unified?: CategorySummaryItem[];
|
||||
}
|
||||
|
||||
export interface TrendDataPoint {
|
||||
Date: string;
|
||||
TotalIncome: number;
|
||||
TotalExpense: number;
|
||||
Balance: number;
|
||||
Count: number;
|
||||
}
|
||||
|
||||
export interface TrendDataResponse {
|
||||
period: 'day' | 'week' | 'month' | 'year';
|
||||
currency?: CurrencyCode;
|
||||
data_points: TrendDataPoint[];
|
||||
}
|
||||
|
||||
export interface SummaryParams {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
target_currency?: CurrencyCode;
|
||||
conversion_date?: string;
|
||||
}
|
||||
|
||||
export interface CategoryParams {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
type: 'income' | 'expense';
|
||||
target_currency?: CurrencyCode;
|
||||
conversion_date?: string;
|
||||
}
|
||||
|
||||
export interface TrendParams {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
period: 'day' | 'week' | 'month' | 'year';
|
||||
currency?: CurrencyCode;
|
||||
}
|
||||
|
||||
export interface ExportParams {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
format: 'pdf' | 'excel';
|
||||
target_currency?: CurrencyCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transaction summary for a date range
|
||||
*/
|
||||
export const getTransactionSummary = async (
|
||||
params: SummaryParams
|
||||
): Promise<TransactionSummaryResponse> => {
|
||||
const response = await api.get<{ success: boolean; data: TransactionSummaryResponse }>(
|
||||
'/reports/summary',
|
||||
params as unknown as Record<string, string | number | boolean | undefined>
|
||||
);
|
||||
if (!response.data) {
|
||||
throw new Error('Failed to get transaction summary');
|
||||
}
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get category summary for a date range
|
||||
*/
|
||||
export const getCategorySummary = async (
|
||||
params: CategoryParams
|
||||
): Promise<CategorySummaryResponse> => {
|
||||
const response = await api.get<{ success: boolean; data: CategorySummaryResponse }>(
|
||||
'/reports/category',
|
||||
params as unknown as Record<string, string | number | boolean | undefined>
|
||||
);
|
||||
if (!response.data) {
|
||||
throw new Error('Failed to get category summary');
|
||||
}
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get trend data for a date range
|
||||
*/
|
||||
export const getTrendData = async (
|
||||
params: TrendParams
|
||||
): Promise<TrendDataResponse> => {
|
||||
const response = await api.get<{ success: boolean; data: TrendDataResponse }>(
|
||||
'/reports/trend',
|
||||
params as unknown as Record<string, string | number | boolean | undefined>
|
||||
);
|
||||
if (!response.data) {
|
||||
throw new Error('Failed to get trend data');
|
||||
}
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Export report as PDF or Excel
|
||||
* Downloads the file directly to the user's device
|
||||
*/
|
||||
export const exportReport = async (params: ExportParams): Promise<void> => {
|
||||
// Use fetch directly for blob response
|
||||
const response = await fetch(`${API_BASE_URL}/reports/export`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `Export failed with status: ${response.status}`);
|
||||
}
|
||||
|
||||
// Get the blob from response
|
||||
const blob = await response.blob();
|
||||
|
||||
// Create a temporary URL for the blob
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
// Create a temporary anchor element and trigger download
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// Generate filename based on date range and format
|
||||
const filename = `report_${params.start_date.replace(/-/g, '')}_to_${params.end_date.replace(/-/g, '')}.${params.format === 'pdf' ? 'pdf' : 'xlsx'}`;
|
||||
link.download = filename;
|
||||
|
||||
// Trigger the download
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export default {
|
||||
getTransactionSummary,
|
||||
getCategorySummary,
|
||||
getTrendData,
|
||||
exportReport,
|
||||
};
|
||||
67
copy/src/services/tagService.ts
Normal file
67
copy/src/services/tagService.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Tag Service - API calls for tag management
|
||||
*/
|
||||
|
||||
import api from './api';
|
||||
import type { Tag, ApiResponse } from '../types';
|
||||
|
||||
export interface TagFormInput {
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags
|
||||
*/
|
||||
export async function getTags(): Promise<Tag[]> {
|
||||
const response = await api.get<ApiResponse<Tag[]>>('/tags');
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single tag by ID
|
||||
*/
|
||||
export async function getTag(id: number): Promise<Tag> {
|
||||
const response = await api.get<ApiResponse<Tag>>(`/tags/${id}`);
|
||||
if (!response.data) {
|
||||
throw new Error('Tag not found');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tag
|
||||
*/
|
||||
export async function createTag(data: TagFormInput): Promise<Tag> {
|
||||
const response = await api.post<ApiResponse<Tag>>('/tags', data);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to create tag');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing tag
|
||||
*/
|
||||
export async function updateTag(id: number, data: Partial<TagFormInput>): Promise<Tag> {
|
||||
const response = await api.put<ApiResponse<Tag>>(`/tags/${id}`, data);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to update tag');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tag
|
||||
*/
|
||||
export async function deleteTag(id: number): Promise<void> {
|
||||
await api.delete<ApiResponse<void>>(`/tags/${id}`);
|
||||
}
|
||||
|
||||
export default {
|
||||
getTags,
|
||||
getTag,
|
||||
createTag,
|
||||
updateTag,
|
||||
deleteTag,
|
||||
};
|
||||
107
copy/src/services/templateService.ts
Normal file
107
copy/src/services/templateService.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import api from './api';
|
||||
import type { ApiResponse } from '../types';
|
||||
|
||||
// Types
|
||||
export interface TransactionTemplate {
|
||||
id: number;
|
||||
user_id?: number;
|
||||
name: string;
|
||||
amount: number;
|
||||
type: 'income' | 'expense' | 'transfer';
|
||||
category_id: number;
|
||||
account_id: number;
|
||||
currency: string;
|
||||
note?: string;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
category?: {
|
||||
id: number;
|
||||
name: string;
|
||||
icon?: string;
|
||||
type: string;
|
||||
};
|
||||
account?: {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
icon?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TemplateInput {
|
||||
name: string;
|
||||
amount: number;
|
||||
type: 'income' | 'expense' | 'transfer';
|
||||
category_id: number;
|
||||
account_id: number;
|
||||
currency?: string;
|
||||
note?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
// API Functions
|
||||
|
||||
/**
|
||||
* Get all transaction templates
|
||||
*/
|
||||
export async function getAllTemplates(): Promise<TransactionTemplate[]> {
|
||||
const response = await api.get<ApiResponse<TransactionTemplate[]>>('/templates');
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a template by ID
|
||||
*/
|
||||
export async function getTemplate(id: number): Promise<TransactionTemplate> {
|
||||
const response = await api.get<ApiResponse<TransactionTemplate>>(`/templates/${id}`);
|
||||
if (!response.data) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new template
|
||||
*/
|
||||
export async function createTemplate(input: TemplateInput): Promise<TransactionTemplate> {
|
||||
const response = await api.post<ApiResponse<TransactionTemplate>>('/templates', input);
|
||||
if (!response.data) {
|
||||
throw new Error('Failed to create template');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing template
|
||||
*/
|
||||
export async function updateTemplate(id: number, input: TemplateInput): Promise<TransactionTemplate> {
|
||||
const response = await api.put<ApiResponse<TransactionTemplate>>(`/templates/${id}`, input);
|
||||
if (!response.data) {
|
||||
throw new Error('Failed to update template');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a template
|
||||
*/
|
||||
export async function deleteTemplate(id: number): Promise<void> {
|
||||
await api.delete(`/templates/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update template sort order
|
||||
*/
|
||||
export async function updateSortOrder(ids: number[]): Promise<void> {
|
||||
await api.put('/templates/sort', { ids });
|
||||
}
|
||||
|
||||
export default {
|
||||
getAllTemplates,
|
||||
getTemplate,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
updateSortOrder,
|
||||
};
|
||||
252
copy/src/services/transactionService.ts
Normal file
252
copy/src/services/transactionService.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Transaction Service - API calls for transaction management
|
||||
* Implements requirements 1.4 (view transactions sorted by time descending)
|
||||
*/
|
||||
|
||||
import api from './api';
|
||||
import type {
|
||||
Transaction,
|
||||
TransactionFormInput,
|
||||
ApiResponse,
|
||||
PaginatedResponse,
|
||||
TransactionType,
|
||||
CurrencyCode,
|
||||
Tag,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Transaction filter parameters
|
||||
*/
|
||||
export interface TransactionFilter {
|
||||
/** Filter by start date (inclusive) */
|
||||
startDate?: string;
|
||||
/** Filter by end date (inclusive) */
|
||||
endDate?: string;
|
||||
/** Filter by category ID */
|
||||
categoryId?: number;
|
||||
/** Filter by account ID */
|
||||
accountId?: number;
|
||||
/** Filter by transaction type */
|
||||
type?: TransactionType;
|
||||
/** Search in notes */
|
||||
search?: string;
|
||||
/** Page number (1-based) */
|
||||
page?: number;
|
||||
/** Items per page */
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API response transaction to frontend Transaction type
|
||||
* Converts snake_case fields to camelCase
|
||||
*/
|
||||
function transformTransaction(apiTransaction: Record<string, unknown>): Transaction {
|
||||
return {
|
||||
id: apiTransaction.id as number,
|
||||
amount: apiTransaction.amount as number,
|
||||
type: apiTransaction.type as TransactionType,
|
||||
categoryId: (apiTransaction.category_id ?? apiTransaction.categoryId) as number,
|
||||
accountId: (apiTransaction.account_id ?? apiTransaction.accountId) as number,
|
||||
currency: apiTransaction.currency as CurrencyCode,
|
||||
transactionDate: (apiTransaction.transaction_date ?? apiTransaction.transactionDate) as string,
|
||||
note: apiTransaction.note as string | undefined,
|
||||
imagePath: (apiTransaction.image_path ?? apiTransaction.imagePath) as string | undefined,
|
||||
tags: (apiTransaction.tags as Tag[]) || [],
|
||||
recurringId: (apiTransaction.recurring_id ?? apiTransaction.recurringId) as number | undefined,
|
||||
createdAt: (apiTransaction.created_at ?? apiTransaction.createdAt) as string,
|
||||
updatedAt: (apiTransaction.updated_at ?? apiTransaction.updatedAt) as string,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transactions with optional filtering and pagination
|
||||
* Results are sorted by transaction date descending (newest first) per requirement 1.4
|
||||
*/
|
||||
export async function getTransactions(
|
||||
filter?: TransactionFilter
|
||||
): Promise<PaginatedResponse<Transaction>> {
|
||||
const params: Record<string, string | number | boolean | undefined> = {};
|
||||
|
||||
if (filter) {
|
||||
if (filter.startDate) params.start_date = filter.startDate;
|
||||
if (filter.endDate) params.end_date = filter.endDate;
|
||||
if (filter.categoryId) params.category_id = filter.categoryId;
|
||||
if (filter.accountId) params.account_id = filter.accountId;
|
||||
if (filter.type) params.type = filter.type;
|
||||
if (filter.search) params.note_search = filter.search;
|
||||
if (filter.page) params.offset = ((filter.page - 1) * (filter.pageSize || 20)).toString();
|
||||
if (filter.pageSize) params.limit = filter.pageSize;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get<{
|
||||
success: boolean;
|
||||
data: Record<string, unknown>[];
|
||||
meta?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
total_count?: number;
|
||||
total_pages?: number;
|
||||
};
|
||||
}>('/transactions', params);
|
||||
|
||||
// Handle backend response format
|
||||
if (response && response.success && Array.isArray(response.data)) {
|
||||
return {
|
||||
items: response.data.map(transformTransaction),
|
||||
total: response.meta?.total_count || 0,
|
||||
page: response.meta?.page || 1,
|
||||
pageSize: response.meta?.page_size || 20,
|
||||
totalPages: response.meta?.total_pages || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for unexpected response structure
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching transactions:', error);
|
||||
// Return empty result on error
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single transaction by ID
|
||||
*/
|
||||
export async function getTransaction(id: number): Promise<Transaction> {
|
||||
const response = await api.get<ApiResponse<Transaction>>(`/transactions/${id}`);
|
||||
if (!response.data) {
|
||||
throw new Error('Transaction not found');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new transaction
|
||||
*/
|
||||
export async function createTransaction(data: TransactionFormInput): Promise<Transaction> {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const payload = {
|
||||
amount: data.amount,
|
||||
type: data.type,
|
||||
category_id: data.categoryId,
|
||||
account_id: data.accountId,
|
||||
currency: data.currency,
|
||||
transaction_date: data.transactionDate,
|
||||
note: data.note,
|
||||
tag_ids: data.tagIds,
|
||||
};
|
||||
const response = await api.post<ApiResponse<Transaction>>('/transactions', payload);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to create transaction');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing transaction
|
||||
*/
|
||||
export async function updateTransaction(
|
||||
id: number,
|
||||
data: Partial<TransactionFormInput>
|
||||
): Promise<Transaction> {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (data.amount !== undefined) payload.amount = data.amount;
|
||||
if (data.type !== undefined) payload.type = data.type;
|
||||
if (data.categoryId !== undefined) payload.category_id = data.categoryId;
|
||||
if (data.accountId !== undefined) payload.account_id = data.accountId;
|
||||
if (data.currency !== undefined) payload.currency = data.currency;
|
||||
if (data.transactionDate !== undefined) payload.transaction_date = data.transactionDate;
|
||||
if (data.note !== undefined) payload.note = data.note;
|
||||
if (data.tagIds !== undefined) payload.tag_ids = data.tagIds;
|
||||
|
||||
const response = await api.put<ApiResponse<Transaction>>(`/transactions/${id}`, payload);
|
||||
if (!response.data) {
|
||||
throw new Error(response.error || 'Failed to update transaction');
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a transaction
|
||||
*/
|
||||
export async function deleteTransaction(id: number): Promise<void> {
|
||||
await api.delete<ApiResponse<void>>(`/transactions/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group transactions by date
|
||||
*/
|
||||
export function groupTransactionsByDate(transactions: Transaction[] | undefined): Map<string, Transaction[]> {
|
||||
const groups = new Map<string, Transaction[]>();
|
||||
|
||||
if (!transactions || !Array.isArray(transactions)) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
for (const transaction of transactions) {
|
||||
// Handle both camelCase (transactionDate) and snake_case (transaction_date) from API
|
||||
const dateValue = transaction.transactionDate || (transaction as unknown as Record<string, unknown>)['transaction_date'] as string;
|
||||
if (!dateValue) {
|
||||
continue; // Skip transactions without a date
|
||||
}
|
||||
const date = dateValue.split('T')[0];
|
||||
const existing = groups.get(date) || [];
|
||||
existing.push(transaction);
|
||||
groups.set(date, existing);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total income from transactions
|
||||
*/
|
||||
export function calculateTotalIncome(transactions: Transaction[] | undefined): number {
|
||||
if (!transactions || !Array.isArray(transactions)) {
|
||||
return 0;
|
||||
}
|
||||
return transactions.filter((t) => t.type === 'income').reduce((sum, t) => sum + t.amount, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total expense from transactions
|
||||
*/
|
||||
export function calculateTotalExpense(transactions: Transaction[] | undefined): number {
|
||||
if (!transactions || !Array.isArray(transactions)) {
|
||||
return 0;
|
||||
}
|
||||
return transactions.filter((t) => t.type === 'expense').reduce((sum, t) => sum + t.amount, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate balance (income - expense)
|
||||
*/
|
||||
export function calculateBalance(transactions: Transaction[] | undefined): number {
|
||||
return calculateTotalIncome(transactions) - calculateTotalExpense(transactions);
|
||||
}
|
||||
|
||||
export default {
|
||||
getTransactions,
|
||||
getTransaction,
|
||||
createTransaction,
|
||||
updateTransaction,
|
||||
deleteTransaction,
|
||||
groupTransactionsByDate,
|
||||
calculateTotalIncome,
|
||||
calculateTotalExpense,
|
||||
calculateBalance,
|
||||
};
|
||||
Reference in New Issue
Block a user