feat: 初始化财务管理应用前端项目,包含账户、预算、交易、报表、设置等核心功能模块。

This commit is contained in:
2026-01-26 01:45:39 +08:00
parent fd7cb4485c
commit 8eaa4dbd11
212 changed files with 30536 additions and 186 deletions

View File

@@ -5,25 +5,52 @@
import api from './api';
import type { Account, AccountFormInput, TransferFormInput, ApiResponse } from '../types';
/**
* Map account data from API (snake_case) to frontend (camelCase)
*/
function mapAccountFromApi(data: Record<string, unknown>): Account {
return {
id: data.id as number,
name: data.name as string,
type: data.type as Account['type'],
balance: data.balance as number,
currency: data.currency as Account['currency'],
icon: data.icon as string,
isCredit: data.is_credit as boolean,
billingDate: data.billing_date as number | undefined,
paymentDate: data.payment_date as number | undefined,
createdAt: data.created_at as string,
updatedAt: data.updated_at as string,
sortOrder: data.sort_order as number,
warningThreshold: data.warning_threshold as number | undefined,
lastSyncTime: data.last_sync_time as string | undefined,
accountCode: data.account_code as string | undefined,
accountType: data.account_type as 'asset' | 'liability' | undefined,
};
}
/**
* Get all accounts
*/
export async function getAccounts(): Promise<Account[]> {
const response = await api.get<ApiResponse<Account[]>>('/accounts');
return response.data || [];
const response = await api.get<ApiResponse<Record<string, unknown>[]>>('/accounts');
const rawData = response.data || [];
return rawData.map(mapAccountFromApi);
}
/**
* Get a single account by ID
*/
export async function getAccount(id: number): Promise<Account> {
const response = await api.get<ApiResponse<Account>>(`/accounts/${id}`);
const response = await api.get<ApiResponse<Record<string, unknown>>>(`/accounts/${id}`);
if (!response.data) {
throw new Error('Account not found');
}
return response.data;
return mapAccountFromApi(response.data);
}
/**
* Create a new account
*/

View File

@@ -25,35 +25,65 @@ export interface PiggyBankTransactionInput {
note?: string;
}
/**
* Map piggy bank data from API (snake_case) to frontend (camelCase)
*/
function mapPiggyBankFromApi(data: Record<string, unknown>): PiggyBank {
return {
id: data.id as number,
name: data.name as string,
targetAmount: data.target_amount as number,
currentAmount: data.current_amount as number,
type: data.type as PiggyBankType,
targetDate: data.target_date as string | undefined,
linkedAccountId: data.linked_account_id as number | undefined,
autoRule: data.auto_rule as string | undefined,
progress: ((data.current_amount as number) / (data.target_amount as number)) * 100 || 0,
createdAt: data.created_at as string,
};
}
/**
* Get all piggy banks
*/
export async function getPiggyBanks(): Promise<PiggyBank[]> {
const response = await api.get<ApiResponse<PiggyBank[]>>('/piggy-banks');
return response.data || [];
const response = await api.get<ApiResponse<Record<string, unknown>[]>>('/piggy-banks');
const rawData = response.data || [];
return rawData.map(mapPiggyBankFromApi);
}
/**
* 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}`);
const response = await api.get<ApiResponse<Record<string, unknown>>>(`/piggy-banks/${id}`);
if (!response.data) {
throw new Error('Piggy bank not found');
}
return response.data;
return mapPiggyBankFromApi(response.data);
}
/**
* Create a new piggy bank
*/
export async function createPiggyBank(data: PiggyBankFormInput): Promise<PiggyBank> {
// Convert target_date to RFC3339 format (Go expects full timestamp)
let formattedTargetDate: string | undefined;
if (data.targetDate) {
// If it's just YYYY-MM-DD, append T00:00:00Z to make it RFC3339 compliant
if (data.targetDate.length === 10 && !data.targetDate.includes('T')) {
formattedTargetDate = `${data.targetDate}T00:00:00Z`;
} else {
formattedTargetDate = data.targetDate;
}
}
// Convert camelCase to snake_case for backend
const payload = {
name: data.name,
target_amount: data.targetAmount,
type: data.type,
target_date: data.targetDate,
target_date: formattedTargetDate,
linked_account_id: data.linkedAccountId,
auto_rule: data.autoRule,
};
@@ -71,15 +101,25 @@ export async function updatePiggyBank(
id: number,
data: Partial<PiggyBankFormInput>
): Promise<PiggyBank> {
// Convert target_date to RFC3339 format (Go expects full timestamp)
let formattedTargetDate: string | undefined;
if (data.targetDate !== undefined) {
if (data.targetDate && data.targetDate.length === 10 && !data.targetDate.includes('T')) {
formattedTargetDate = `${data.targetDate}T00:00:00Z`;
} else {
formattedTargetDate = data.targetDate;
}
}
// 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.targetDate !== undefined) payload.target_date = formattedTargetDate;
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');
@@ -199,25 +239,25 @@ export function estimateCompletionDate(
1,
Math.ceil((now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24))
);
// Prevent division issues
if (!isFinite(daysElapsed) || daysElapsed <= 0) return null;
const dailyRate = currentAmount / daysElapsed;
if (!isFinite(dailyRate) || dailyRate <= 0) return null;
const remainingAmount = targetAmount - currentAmount;
const daysRemaining = Math.ceil(remainingAmount / dailyRate);
// Sanity check: don't estimate more than 10 years out
if (!isFinite(daysRemaining) || daysRemaining > 3650) return null;
const estimatedDate = new Date(now);
estimatedDate.setDate(estimatedDate.getDate() + daysRemaining);
// Final validation
if (isNaN(estimatedDate.getTime())) return null;
return estimatedDate.toISOString().split('T')[0];
}