diff --git a/copy/src/App.css b/copy/src/App.css new file mode 100644 index 0000000..307ab4f --- /dev/null +++ b/copy/src/App.css @@ -0,0 +1,325 @@ +/* Base styles - Premium Glassmorphism Theme */ +:root { + /* + * Color Palette - Vibrant & Premium + * Primary: Indigo/Violet Gradients + * Secondary: Cyan/Teal + * Accent: Coral/Orange + */ + + /* Light Theme Base */ + --color-bg: #f8fafc; + --color-bg-page: #ffffeb; + /* Very subtle warm tint */ + --color-text: #0f172a; + --color-text-secondary: #475569; + --color-text-muted: #94a3b8; + + /* Primary Colors - Enterprise Yellow/Amber */ + --color-primary: #d97706; + /* Amber 600 */ + --color-primary-dark: #b45309; + /* Amber 700 */ + --color-primary-light: #fbbf24; + /* Amber 400 */ + --color-primary-lighter: rgba(217, 119, 6, 0.1); + --gradient-primary: linear-gradient(135deg, #d97706 0%, #f59e0b 100%); + --gradient-primary-hover: linear-gradient(135deg, #b45309 0%, #d97706 100%); + + /* Accent Colors - Navy/Slate Blue */ + --color-accent: #1e293b; + /* Slate 800 */ + --color-accent-hover: #0f172a; + /* Slate 900 */ + --color-accent-light: rgba(30, 41, 59, 0.1); + --gradient-accent: linear-gradient(135deg, #1e293b 0%, #334155 100%); + + /* Status Colors */ + --color-success: #059669; + /* Emerald 600 */ + --color-success-light: rgba(5, 150, 105, 0.1); + --color-warning: #f59e0b; + /* Amber 500 - matches primary but kept for semantic */ + --color-warning-light: rgba(245, 158, 11, 0.1); + --color-error: #dc2626; + /* Red 600 */ + --color-error-light: rgba(220, 38, 38, 0.1); + + /* Glassmorphism Variables */ + --glass-bg: rgba(255, 255, 255, 0.7); + --glass-border: rgba(0, 0, 0, 0.08); + /* Changed from white to dark for visibility on light bg */ + --glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07); + --glass-blur: blur(6px); + /* Reduced from 8px for performance */ + --glass-panel-bg: rgba(255, 255, 255, 0.85); + + /* Background Colors */ + --color-bg-tertiary: #f1f5f9; + /* Slate 100 */ + + /* Shadows & Depth */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + --shadow-glow-primary: 0 0 15px rgba(217, 119, 6, 0.3); + + /* Spacing */ + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; + + /* Border Radius */ + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + --radius-2xl: 1rem; + --radius-full: 9999px; + + /* Typography Scale - Refined for Enterprise */ + --font-xs: 0.75rem; + /* 12px */ + --font-sm: 0.875rem; + /* 14px */ + --font-base: 1rem; + /* 16px */ + --font-lg: 1.125rem; + /* 18px */ + --font-xl: 1.25rem; + /* 20px */ + --font-2xl: 1.5rem; + /* 24px */ + --font-3xl: 2rem; + /* 32px */ + --font-3xl: 2rem; + /* 32px */ + --font-4xl: 2.5rem; + /* 40px */ + + /* Animation Durations */ + --duration-fast: 200ms; + --duration-normal: 300ms; + --duration-slow: 500ms; + + /* Easing */ + --ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); +} + +/* Dark Theme */ +.dark { + --color-bg: #0f172a; + --color-bg-page: #020617; + --color-text: #f8fafc; + --color-text-secondary: #94a3b8; + --color-text-muted: #64748b; + --color-bg-tertiary: #1e293b; + /* Slate 800 */ + + --color-primary: #fbbf24; + /* Amber 400 */ + --color-primary-dark: #d97706; + /* Amber 600 */ + --color-primary-light: rgba(251, 191, 36, 0.2); + --gradient-primary: linear-gradient(135deg, #fbbf24 0%, #d97706 100%); + + --glass-bg: rgba(15, 23, 42, 0.6); + --glass-border: rgba(255, 255, 255, 0.1); + --glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3); + --glass-panel-bg: rgba(30, 41, 59, 0.7); + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4); +} + +/* App Container */ +.app { + min-height: 100vh; + background-color: var(--color-bg-page); + color: var(--color-text); + color: var(--color-text); + font-family: 'Inter', sans-serif; + line-height: 1.6; + /* Improved readability */ + letter-spacing: -0.01em; + /* Tighter, more modern */ + /* Add subtle mesh gradient background */ + background-image: + radial-gradient(at 0% 0%, rgba(217, 119, 6, 0.05) 0px, transparent 50%), + radial-gradient(at 100% 0%, rgba(30, 41, 59, 0.05) 0px, transparent 50%), + radial-gradient(at 100% 100%, rgba(5, 150, 105, 0.05) 0px, transparent 50%), + radial-gradient(at 0% 100%, rgba(251, 191, 36, 0.05) 0px, transparent 50%); + background-attachment: fixed; + transition: background-color 0.3s ease; +} + +/* Glassmorphism Utilities */ +.glass-panel { + background: var(--glass-panel-bg); + -webkit-backdrop-filter: var(--glass-blur); + backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + box-shadow: var(--glass-shadow); + border-radius: var(--radius-xl); +} + +.glass-card { + background: var(--glass-bg); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + border: 1px solid var(--glass-border); + box-shadow: var(--shadow-sm); + border-radius: var(--radius-lg); + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.glass-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg), var(--shadow-glow-primary); + border-color: rgba(217, 119, 6, 0.4); +} + +/* Buttons */ +.btn-primary { + background: var(--gradient-primary); + color: white; + border: none; + padding: var(--spacing-sm) var(--spacing-xl); + border-radius: var(--radius-full); + font-weight: 600; + font-size: var(--font-sm); + cursor: pointer; + box-shadow: 0 4px 6px rgba(217, 119, 6, 0.25); + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 8px 12px rgba(217, 119, 6, 0.35); + background: var(--gradient-primary-hover); +} + +.btn-primary:active { + transform: translateY(0); +} + +/* Inputs with floating-like feel */ +.input { + width: 100%; + padding: var(--spacing-md); + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + color: var(--color-text); + font-size: var(--font-base); + transition: all 0.2s ease; +} + +.input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-lighter); + /* Use lighter opacity variable */ + background: var(--color-bg); +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideUpFade { + 0% { + opacity: 0; + transform: translateY(10px); + } + + 100% { + opacity: 1; + transform: translateY(0); + } +} + + + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + + 100% { + background-position: 200% 0; + } +} + +.animate-fade-in { + animation: fadeIn 0.5s ease-out forwards; +} + +.animate-slide-up { + animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +/* Staggered animation delays */ +.delay-100 { + animation-delay: 100ms; +} + +.delay-200 { + animation-delay: 200ms; +} + +.delay-300 { + animation-delay: 300ms; +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(156, 163, 175, 0.3); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(156, 163, 175, 0.5); +} + +/* Custom Selection Color */ +::selection { + background: var(--color-primary-light); + /* Amber 200 */ + color: var(--color-text); +} \ No newline at end of file diff --git a/copy/src/App.tsx b/copy/src/App.tsx new file mode 100644 index 0000000..f09102b --- /dev/null +++ b/copy/src/App.tsx @@ -0,0 +1,13 @@ +import { RouterProvider } from 'react-router-dom'; +import { router } from './router'; +import './App.css'; + +/** + * Main Application Component + * Local-first accounting application + */ +function App() { + return ; +} + +export default App; diff --git a/copy/src/assets/react.svg b/copy/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/copy/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/copy/src/components/account/AccountCard/AccountCard.css b/copy/src/components/account/AccountCard/AccountCard.css new file mode 100644 index 0000000..dc46d9a --- /dev/null +++ b/copy/src/components/account/AccountCard/AccountCard.css @@ -0,0 +1,238 @@ +/** + * AccountCard Component Styles + */ + +.account-card { + position: relative; + display: flex; + flex-direction: column; + padding: 1.25rem; + border-radius: var(--radius-xl, 16px); + background: var(--glass-panel-bg, rgba(255, 255, 255, 0.7)); + border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.5)); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; + height: 100%; + min-height: 160px; + justify-content: space-between; + cursor: default; +} + +.account-card--clickable { + cursor: pointer; +} + +.account-card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1); + border-color: rgba(255, 255, 255, 0.8); +} + +.account-card--selected { + box-shadow: 0 0 0 2px var(--color-primary); + transform: scale(1.02); +} + +/* Background Decoration for Glass Effect */ +.account-card__background-decoration { + position: absolute; + top: -50px; + right: -50px; + width: 140px; + height: 140px; + border-radius: 50%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 70%); + opacity: 0.1; + pointer-events: none; + transition: opacity 0.3s ease; +} + +.account-card:hover .account-card__background-decoration { + opacity: 0.2; +} + +/* Themes per account type */ +.account-card--cash::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(52, 211, 153, 0.1) 100%); + z-index: -1; +} + +.account-card--debit::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(96, 165, 250, 0.1) 100%); + z-index: -1; +} + +.account-card--credit::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(239, 68, 68, 0.05) 0%, rgba(248, 113, 113, 0.1) 100%); + z-index: -1; +} + +.account-card--ewallet::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(245, 158, 11, 0.05) 0%, rgba(251, 191, 36, 0.1) 100%); + z-index: -1; +} + +.account-card--investment::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(139, 92, 246, 0.05) 0%, rgba(167, 139, 250, 0.1) 100%); + z-index: -1; +} + +.account-card--loan::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(107, 114, 128, 0.05) 0%, rgba(156, 163, 175, 0.1) 100%); + z-index: -1; +} + +/* Header Section */ +.account-card__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.account-card__icon-wrapper { + width: 44px; + height: 44px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.9); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + font-size: 1.5rem; +} + +/* Actions Overlay - Only visible on hover */ +.account-card__actions-overlay { + display: flex; + gap: 0.5rem; + opacity: 0; + transform: translateX(10px); + transition: all 0.2s ease; +} + +.account-card:hover .account-card__actions-overlay { + opacity: 1; + transform: translateX(0); +} + +.account-card__action-btn { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + background: rgba(255, 255, 255, 0.8); + color: var(--color-text-secondary); + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.account-card__action-btn:hover { + transform: scale(1.1); + color: var(--color-primary); +} + +.account-card__action-btn--delete:hover { + color: var(--color-error); + background: #fee2e2; +} + +/* Body Section */ +.account-card__body { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.account-card__balance-section { + display: flex; + align-items: baseline; + gap: 0.25rem; +} + +.account-card__currency-symbol { + font-size: 0.875rem; + color: var(--color-text-secondary); + font-weight: 600; +} + +.account-card__balance { + font-family: 'Outfit', sans-serif; + /* Ensure you have this font or fallback */ + font-size: 1.75rem; + font-weight: 700; + color: var(--color-text); + line-height: 1.2; + letter-spacing: -0.02em; +} + +.account-card__balance--negative { + color: var(--color-error); + /* e.g., #ef4444 */ +} + +/* Info Section */ +.account-card__info { + display: flex; + flex-direction: column; +} + +.account-card__name { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.account-card__meta { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.account-card__type { + font-size: 0.75rem; + color: var(--color-text-secondary); + background: rgba(0, 0, 0, 0.04); + padding: 2px 8px; + border-radius: 4px; +} + +.account-card__tag { + font-size: 0.65rem; + background: rgba(99, 102, 241, 0.1); + color: var(--color-primary); + padding: 2px 6px; + border-radius: 4px; + font-weight: 600; + text-transform: uppercase; +} \ No newline at end of file diff --git a/copy/src/components/account/AccountCard/AccountCard.tsx b/copy/src/components/account/AccountCard/AccountCard.tsx new file mode 100644 index 0000000..213516d --- /dev/null +++ b/copy/src/components/account/AccountCard/AccountCard.tsx @@ -0,0 +1,146 @@ +/** + * AccountCard Component + * Displays a single account with balance, type, and icon + */ + +import React from 'react'; +import type { Account, AccountType } from '../../../types'; +import { formatCurrency } from '../../../utils/format'; +import { Icon } from '@iconify/react'; +import './AccountCard.css'; + +interface AccountCardProps { + account: Account; + onClick?: (account: Account) => void; + onEdit?: (account: Account) => void; + onDelete?: (account: Account) => void; + selected?: boolean; +} + +/** + * Account type labels in Chinese + */ +const ACCOUNT_TYPE_LABELS: Record = { + cash: '现金', + debit_card: '储蓄卡', + credit_card: '信用卡', + e_wallet: '电子钱包', + credit_line: '信用账户', + investment: '投资账户', +}; + +/** + * Default icons for account types + */ +const DEFAULT_ICONS: Record = { + cash: '💵', + debit_card: '💳', + credit_card: '💳', + e_wallet: '📱', + credit_line: '🏦', + investment: '📈', +}; + +export const AccountCard: React.FC = ({ + account, + onClick, + onEdit, + onDelete, + selected = false, +}) => { + const handleClick = () => { + onClick?.(account); + }; + + const handleEdit = (e: React.MouseEvent) => { + e.stopPropagation(); + onEdit?.(account); + }; + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete?.(account); + }; + + const icon = account.icon || DEFAULT_ICONS[account.type] || '💰'; + const isNegative = account.balance < 0; + const typeLabel = ACCOUNT_TYPE_LABELS[account.type] || account.type; + + // Determine card theme based on account type + const getThemeClass = (type: AccountType) => { + switch (type) { + case 'cash': return 'account-card--cash'; + case 'debit_card': return 'account-card--debit'; + case 'credit_card': return 'account-card--credit'; + case 'e_wallet': return 'account-card--ewallet'; + case 'investment': return 'account-card--investment'; + case 'credit_line': return 'account-card--loan'; + default: return 'account-card--default'; + } + }; + + return ( +
{ + if (onClick && (e.key === 'Enter' || e.key === ' ')) { + handleClick(); + } + }} + > +
+ +
+
+
{icon}
+
+
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+
+ +
+
+ {account.currency} + + {formatCurrency(account.balance, account.currency).replace(/[^0-9.,-]/g, '')} + +
+ +
+

{account.name}

+
+ {typeLabel} + {account.isCredit && 信用} +
+
+
+
+ ); +}; + +export default AccountCard; diff --git a/copy/src/components/account/AccountCard/index.ts b/copy/src/components/account/AccountCard/index.ts new file mode 100644 index 0000000..62bbc02 --- /dev/null +++ b/copy/src/components/account/AccountCard/index.ts @@ -0,0 +1 @@ +export { AccountCard, default } from './AccountCard'; diff --git a/copy/src/components/account/AccountForm/AccountForm.css b/copy/src/components/account/AccountForm/AccountForm.css new file mode 100644 index 0000000..0c55041 --- /dev/null +++ b/copy/src/components/account/AccountForm/AccountForm.css @@ -0,0 +1,248 @@ +/** + * AccountForm Component Styles + */ + +.account-form { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; + background-color: var(--color-bg); + border-radius: 16px; + max-width: 500px; + margin: 0 auto; +} + +.account-form__title { + margin: 0 0 0.5rem 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text); + text-align: center; +} + +/* Field styles */ +.account-form__field { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.account-form__field--half { + flex: 1; +} + +.account-form__row { + display: flex; + gap: 1rem; +} + +.account-form__label { + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text); +} + +.account-form__required { + color: var(--color-error); +} + +.account-form__input { + padding: 0.75rem 1rem; + border: 1px solid var(--color-border); + border-radius: 8px; + font-size: 1rem; + background-color: var(--color-bg); + color: var(--color-text); + transition: border-color 0.2s ease; +} + +.account-form__input:focus { + outline: none; + border-color: var(--color-primary); +} + +.account-form__input--error { + border-color: var(--color-error); +} + +.account-form__input--number { + flex: 1; +} + +.account-form__input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.account-form__error { + font-size: 0.75rem; + color: var(--color-error); +} + +.account-form__hint { + font-size: 0.75rem; + color: var(--color-text-secondary); +} + +/* Input group for currency + amount */ +.account-form__input-group { + display: flex; + gap: 0.5rem; +} + +.account-form__currency-select { + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: 8px; + font-size: 0.875rem; + background-color: var(--color-bg); + color: var(--color-text); + cursor: pointer; + min-width: 80px; +} + +.account-form__currency-select:focus { + outline: none; + border-color: var(--color-primary); +} + +/* Account type grid */ +.account-form__type-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; +} + +.account-form__type-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + padding: 0.75rem 0.5rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background-color: var(--color-bg); + cursor: pointer; + transition: all 0.2s ease; +} + +.account-form__type-btn:hover { + border-color: var(--color-primary); +} + +.account-form__type-btn--selected { + border-color: var(--color-primary); + background-color: rgba(24, 144, 255, 0.1); +} + +.account-form__type-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.account-form__type-icon { + font-size: 1.5rem; +} + +.account-form__type-label { + font-size: 0.75rem; + color: var(--color-text); +} + +/* Icon grid */ +.account-form__icon-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.account-form__icon-btn { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border); + border-radius: 8px; + background-color: var(--color-bg); + font-size: 1.25rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.account-form__icon-btn:hover { + border-color: var(--color-primary); +} + +.account-form__icon-btn--selected { + border-color: var(--color-primary); + background-color: rgba(24, 144, 255, 0.1); +} + +.account-form__icon-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Form actions */ +.account-form__actions { + display: flex; + gap: 1rem; + margin-top: 0.5rem; +} + +.account-form__btn { + flex: 1; + padding: 0.875rem 1.5rem; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.account-form__btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.account-form__btn--cancel { + background-color: var(--color-bg-secondary); + color: var(--color-text); + border: 1px solid var(--color-border); +} + +.account-form__btn--cancel:hover:not(:disabled) { + background-color: var(--color-border); +} + +.account-form__btn--submit { + background-color: var(--color-primary); + color: #fff; +} + +.account-form__btn--submit:hover:not(:disabled) { + background-color: var(--color-primary-hover); +} + +/* Responsive styles */ +@media (max-width: 480px) { + .account-form { + padding: 1rem; + } + + .account-form__type-grid { + grid-template-columns: repeat(2, 1fr); + } + + .account-form__row { + flex-direction: column; + gap: 1rem; + } + + .account-form__actions { + flex-direction: column-reverse; + } +} diff --git a/copy/src/components/account/AccountForm/AccountForm.tsx b/copy/src/components/account/AccountForm/AccountForm.tsx new file mode 100644 index 0000000..96d9dd2 --- /dev/null +++ b/copy/src/components/account/AccountForm/AccountForm.tsx @@ -0,0 +1,328 @@ +/** + * AccountForm Component + * Form for creating and editing accounts + */ + +import React, { useState, useEffect } from 'react'; +import type { Account, AccountType, CurrencyCode, AccountFormInput } from '../../../types'; +import './AccountForm.css'; + +interface AccountFormProps { + account?: Account; + onSubmit: (data: AccountFormInput) => void; + onCancel: () => void; + loading?: boolean; +} + +/** + * Account type options + */ +const ACCOUNT_TYPES: { value: AccountType; label: string; icon: string }[] = [ + { value: 'cash', label: '现金', icon: '💵' }, + { value: 'debit_card', label: '储蓄卡', icon: '💳' }, + { value: 'credit_card', label: '信用卡', icon: '💳' }, + { value: 'e_wallet', label: '电子钱包', icon: '📱' }, + { value: 'credit_line', label: '信用账户', icon: '🏦' }, + { value: 'investment', label: '投资账户', icon: '📈' }, +]; + +/** + * Currency options + */ +const CURRENCIES: { value: CurrencyCode; label: string }[] = [ + { value: 'CNY', label: '人民币 (CNY)' }, + { value: 'USD', label: '美元 (USD)' }, + { value: 'EUR', label: '欧元 (EUR)' }, + { value: 'JPY', label: '日元 (JPY)' }, + { value: 'GBP', label: '英镑 (GBP)' }, + { value: 'HKD', label: '港币 (HKD)' }, +]; + +/** + * Icon options + */ +const ICONS = ['💵', '💳', '📱', '🏦', '📈', '💰', '🪙', '💎', '🏧', '💴', '💶', '💷']; + +/** + * Credit account types that support negative balance + */ +const CREDIT_TYPES: AccountType[] = ['credit_card', 'credit_line']; + +export const AccountForm: React.FC = ({ + account, + onSubmit, + onCancel, + loading = false, +}) => { + const isEditing = !!account; + + const [formData, setFormData] = useState({ + name: '', + type: 'cash', + balance: 0, + currency: 'CNY', + icon: '💵', + billingDate: undefined, + paymentDate: undefined, + }); + + const [errors, setErrors] = useState>>({}); + + // Initialize form with account data when editing + useEffect(() => { + if (account) { + setFormData({ + name: account.name, + type: account.type, + balance: account.balance, + currency: account.currency, + icon: account.icon, + billingDate: account.billingDate, + paymentDate: account.paymentDate, + }); + } + }, [account]); + + const isCreditType = CREDIT_TYPES.includes(formData.type); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + + setFormData((prev) => ({ + ...prev, + [name]: + name === 'balance' || name === 'billingDate' || name === 'paymentDate' + ? value === '' + ? undefined + : Number(value) + : value, + })); + + // Clear error when field is modified + if (errors[name as keyof AccountFormInput]) { + setErrors((prev) => ({ ...prev, [name]: undefined })); + } + }; + + const handleTypeChange = (type: AccountType) => { + const typeOption = ACCOUNT_TYPES.find((t) => t.value === type); + setFormData((prev) => ({ + ...prev, + type, + icon: typeOption?.icon || prev.icon, + // Clear credit-specific fields if not a credit type + billingDate: CREDIT_TYPES.includes(type) ? prev.billingDate : undefined, + paymentDate: CREDIT_TYPES.includes(type) ? prev.paymentDate : undefined, + })); + }; + + const handleIconSelect = (icon: string) => { + setFormData((prev) => ({ ...prev, icon })); + }; + + const validate = (): boolean => { + const newErrors: Partial> = {}; + + if (!formData.name.trim()) { + newErrors.name = '请输入账户名称'; + } + + if (formData.balance === undefined || isNaN(formData.balance)) { + newErrors.balance = '请输入有效金额'; + } + + // Non-credit accounts cannot have negative balance + if (!isCreditType && formData.balance < 0) { + newErrors.balance = '非信用账户余额不能为负'; + } + + // Validate billing date for credit cards + if (isCreditType && formData.billingDate !== undefined) { + if (formData.billingDate < 1 || formData.billingDate > 31) { + newErrors.billingDate = '账单日应在1-31之间'; + } + } + + // Validate payment date for credit cards + if (isCreditType && formData.paymentDate !== undefined) { + if (formData.paymentDate < 1 || formData.paymentDate > 31) { + newErrors.paymentDate = '还款日应在1-31之间'; + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (validate()) { + onSubmit(formData); + } + }; + + return ( +
+

{isEditing ? '编辑账户' : '新建账户'}

+ + {/* Account Name */} +
+ + + {errors.name && {errors.name}} +
+ + {/* Account Type */} +
+ +
+ {ACCOUNT_TYPES.map((type) => ( + + ))} +
+
+ + {/* Initial Balance */} +
+ +
+ + +
+ {errors.balance && {errors.balance}} + {isCreditType && 信用账户支持负余额(欠款)} +
+ + {/* Credit Card Specific Fields */} + {isCreditType && ( +
+
+ + + {errors.billingDate && ( + {errors.billingDate} + )} +
+
+ + + {errors.paymentDate && ( + {errors.paymentDate} + )} +
+
+ )} + + {/* Icon Selection */} +
+ +
+ {ICONS.map((icon) => ( + + ))} +
+
+ + {/* Form Actions */} +
+ + +
+
+ ); +}; + +export default AccountForm; diff --git a/copy/src/components/account/AccountForm/index.ts b/copy/src/components/account/AccountForm/index.ts new file mode 100644 index 0000000..f224c9a --- /dev/null +++ b/copy/src/components/account/AccountForm/index.ts @@ -0,0 +1 @@ +export { AccountForm, default } from './AccountForm'; diff --git a/copy/src/components/account/AccountList/AccountList.css b/copy/src/components/account/AccountList/AccountList.css new file mode 100644 index 0000000..d9291d5 --- /dev/null +++ b/copy/src/components/account/AccountList/AccountList.css @@ -0,0 +1,238 @@ +/** + * AccountList Component - Glassmorphism Style + */ + +.account-list { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); + width: 100%; +} + +/* Loading state */ +.account-list--loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: calc(var(--spacing-xl) * 2); + color: var(--color-text-secondary); + gap: var(--spacing-md); + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); +} + +.account-list__spinner { + width: 40px; + height: 40px; + border: 3px solid var(--glass-border); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Empty state */ +.account-list--empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: calc(var(--spacing-xl) * 2); + text-align: center; + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px dashed var(--glass-border); + border-radius: var(--radius-lg); +} + +.account-list__empty-icon { + font-size: 3.5rem; + margin-bottom: var(--spacing-md); + opacity: 0.6; +} + +.account-list__empty-message { + color: var(--color-text-secondary); + margin: 0; + font-size: 1rem; +} + +/* Summary section */ +.account-list__summary { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-lg) var(--spacing-xl); + background: linear-gradient(135deg, var(--color-primary), #d97706); + /* Enterprise Amber */ + border-radius: var(--radius-xl); + color: white; + box-shadow: 0 8px 30px rgba(245, 158, 11, 0.3); + position: relative; + overflow: hidden; + margin-bottom: var(--spacing-md); +} + +.account-list__summary::before { + content: ''; + position: absolute; + top: -50%; + right: -20%; + width: 200px; + height: 200px; + background: rgba(255, 255, 255, 0.15); + border-radius: 50%; +} + +.account-list__summary::after { + content: ''; + position: absolute; + bottom: -30%; + left: -10%; + width: 150px; + height: 150px; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; +} + +.account-list__summary-label { + font-size: 0.875rem; + font-weight: 600; + opacity: 0.95; + position: relative; + z-index: 1; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.account-list__summary-value { + font-family: 'Outfit', sans-serif; + font-size: 1.75rem; + font-weight: 800; + position: relative; + z-index: 1; + letter-spacing: -0.02em; +} + +.account-list__summary-value--negative { + color: #fecaca; +} + +/* Group section */ +.account-list__group { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + animation: slideUpFade 0.5s ease-out forwards; +} + +.account-list__group-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 var(--spacing-xs); + margin-bottom: -4px; +} + +.account-list__group-title { + margin: 0; + font-size: 0.875rem; + font-weight: 700; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.account-list__group-title::before { + content: ''; + display: block; + width: 4px; + height: 16px; + background: var(--color-primary); + border-radius: 2px; +} + +.account-list__group-total { + font-family: 'Outfit', sans-serif; + font-size: 1rem; + font-weight: 700; + color: var(--color-text); + background: var(--glass-bg-heavy); + padding: 0.25rem 0.75rem; + border-radius: var(--radius-full); +} + +.account-list__group-total--negative { + color: var(--color-error); + background: rgba(239, 68, 68, 0.1); +} + +/* Grid Layout for Items */ +/* Grid Layout for Items */ +.account-list__group-items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + /* Slightly reduced min-width for better fit */ + gap: var(--spacing-lg); +} + +/* Staggered animation delays */ +.account-list__group:nth-child(1) { + animation-delay: 0.1s; +} + +.account-list__group:nth-child(2) { + animation-delay: 0.2s; +} + +.account-list__group:nth-child(3) { + animation-delay: 0.3s; +} + +.account-list__group:nth-child(4) { + animation-delay: 0.4s; +} + +/* Mobile */ +@media (max-width: 480px) { + .account-list__summary { + padding: var(--spacing-md) var(--spacing-lg); + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-xs); + } + + .account-list__summary-value { + font-size: 1.5rem; + } + + .account-list__group-items { + grid-template-columns: 1fr; + /* Stack on mobile */ + } +} + +/* Reduced Motion */ +@media (prefers-reduced-motion: reduce) { + .account-list__spinner { + animation: none; + } + + .account-list__group { + animation: none; + opacity: 1; + } +} \ No newline at end of file diff --git a/copy/src/components/account/AccountList/AccountList.tsx b/copy/src/components/account/AccountList/AccountList.tsx new file mode 100644 index 0000000..7e755d5 --- /dev/null +++ b/copy/src/components/account/AccountList/AccountList.tsx @@ -0,0 +1,129 @@ +/** + * AccountList Component + * Displays list of all accounts grouped by type + */ + +import React from 'react'; +import type { Account, AccountType } from '../../../types'; +import { AccountCard } from '../AccountCard'; +import { formatCurrency } from '../../../utils/format'; +import { groupAccountsByType, calculateTotalBalance } from '../../../services/accountService'; +import './AccountList.css'; + +interface AccountListProps { + accounts: Account[]; + onAccountClick?: (account: Account) => void; + onAccountEdit?: (account: Account) => void; + onAccountDelete?: (account: Account) => void; + selectedAccountId?: number; + loading?: boolean; + emptyMessage?: string; +} + +/** + * Account type labels in Chinese + */ +const ACCOUNT_TYPE_LABELS: Record = { + cash: '现金账户', + debit_card: '储蓄卡', + credit_card: '信用卡', + e_wallet: '电子钱包', + credit_line: '信用账户', + investment: '投资账户', +}; + +/** + * Account type display order + */ +const ACCOUNT_TYPE_ORDER: AccountType[] = [ + 'cash', + 'debit_card', + 'e_wallet', + 'credit_card', + 'credit_line', + 'investment', +]; + +export const AccountList: React.FC = ({ + accounts, + onAccountClick, + onAccountEdit, + onAccountDelete, + selectedAccountId, + loading = false, + emptyMessage = '暂无账户,点击上方按钮添加', +}) => { + if (loading) { + return ( +
+
+ 加载中... +
+ ); + } + + if (accounts.length === 0) { + return ( +
+ 💰 +

{emptyMessage}

+
+ ); + } + + const groupedAccounts = groupAccountsByType(accounts); + const totalBalance = calculateTotalBalance(accounts); + + // Sort groups by predefined order + const sortedGroups = ACCOUNT_TYPE_ORDER.filter( + (type) => groupedAccounts[type] && groupedAccounts[type].length > 0 + ); + + return ( +
+ {/* Total Balance Summary */} +
+ 总余额 + + {formatCurrency(totalBalance, 'CNY')} + +
+ + {/* Grouped Accounts */} + {sortedGroups.map((type) => { + const typeAccounts = groupedAccounts[type]; + const groupTotal = calculateTotalBalance(typeAccounts); + const typeLabel = ACCOUNT_TYPE_LABELS[type as AccountType] || type; + + return ( +
+
+

{typeLabel}

+ + {formatCurrency(groupTotal, 'CNY')} + +
+
+ {typeAccounts.map((account) => ( + + ))} +
+
+ ); + })} +
+ ); +}; + +export default AccountList; diff --git a/copy/src/components/account/AccountList/index.ts b/copy/src/components/account/AccountList/index.ts new file mode 100644 index 0000000..9507f41 --- /dev/null +++ b/copy/src/components/account/AccountList/index.ts @@ -0,0 +1 @@ +export { AccountList, default } from './AccountList'; diff --git a/copy/src/components/account/TransferForm/TransferForm.css b/copy/src/components/account/TransferForm/TransferForm.css new file mode 100644 index 0000000..be877ed --- /dev/null +++ b/copy/src/components/account/TransferForm/TransferForm.css @@ -0,0 +1,279 @@ +/** + * TransferForm Component Styles + */ + +.transfer-form { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; + background-color: var(--color-bg); + border-radius: 16px; + max-width: 500px; + margin: 0 auto; +} + +.transfer-form__title { + margin: 0 0 0.5rem 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text); + text-align: center; +} + +/* Field styles */ +.transfer-form__field { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.transfer-form__label { + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text); +} + +.transfer-form__required { + color: var(--color-error); +} + +.transfer-form__select { + padding: 0.75rem 1rem; + border: 1px solid var(--color-border); + border-radius: 8px; + font-size: 1rem; + background-color: var(--color-bg); + color: var(--color-text); + cursor: pointer; + transition: border-color 0.2s ease; +} + +.transfer-form__select:focus { + outline: none; + border-color: var(--color-primary); +} + +.transfer-form__select--error { + border-color: var(--color-error); +} + +.transfer-form__select:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.transfer-form__error { + font-size: 0.75rem; + color: var(--color-error); +} + +.transfer-form__balance { + font-size: 0.75rem; + color: var(--color-text-secondary); +} + +/* Swap button */ +.transfer-form__swap { + display: flex; + justify-content: center; + margin: -0.5rem 0; +} + +.transfer-form__swap-btn { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border); + border-radius: 50%; + background-color: var(--color-bg); + font-size: 1.25rem; + cursor: pointer; + transition: all 0.2s ease; + color: var(--color-text); +} + +.transfer-form__swap-btn:hover:not(:disabled) { + border-color: var(--color-primary); + background-color: var(--color-bg-secondary); +} + +.transfer-form__swap-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Amount input */ +.transfer-form__amount-input { + display: flex; + align-items: center; + border: 1px solid var(--color-border); + border-radius: 8px; + overflow: hidden; + transition: border-color 0.2s ease; +} + +.transfer-form__amount-input:focus-within { + border-color: var(--color-primary); +} + +.transfer-form__currency-symbol { + padding: 0.75rem 1rem; + background-color: var(--color-bg-secondary); + color: var(--color-text-secondary); + font-size: 1rem; + font-weight: 500; + border-right: 1px solid var(--color-border); +} + +.transfer-form__input { + flex: 1; + padding: 0.75rem 1rem; + border: none; + font-size: 1.25rem; + font-weight: 500; + background-color: var(--color-bg); + color: var(--color-text); +} + +.transfer-form__input:focus { + outline: none; +} + +.transfer-form__input--error { + color: var(--color-error); +} + +.transfer-form__input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Textarea */ +.transfer-form__textarea { + padding: 0.75rem 1rem; + border: 1px solid var(--color-border); + border-radius: 8px; + font-size: 1rem; + background-color: var(--color-bg); + color: var(--color-text); + resize: vertical; + font-family: inherit; + transition: border-color 0.2s ease; +} + +.transfer-form__textarea:focus { + outline: none; + border-color: var(--color-primary); +} + +.transfer-form__textarea:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Transfer preview */ +.transfer-form__preview { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + background-color: var(--color-bg-secondary); + border-radius: 8px; + gap: 0.5rem; +} + +.transfer-form__preview-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + flex: 1; +} + +.transfer-form__preview-label { + font-size: 0.75rem; + color: var(--color-text-secondary); +} + +.transfer-form__preview-value { + font-size: 1rem; + font-weight: 600; +} + +.transfer-form__preview-value--from { + color: var(--color-error); +} + +.transfer-form__preview-value--to { + color: var(--color-success); +} + +.transfer-form__preview-arrow { + font-size: 1.25rem; + color: var(--color-text-secondary); +} + +/* Form actions */ +.transfer-form__actions { + display: flex; + gap: 1rem; + margin-top: 0.5rem; +} + +.transfer-form__btn { + flex: 1; + padding: 0.875rem 1.5rem; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.transfer-form__btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.transfer-form__btn--cancel { + background-color: var(--color-bg-secondary); + color: var(--color-text); + border: 1px solid var(--color-border); +} + +.transfer-form__btn--cancel:hover:not(:disabled) { + background-color: var(--color-border); +} + +.transfer-form__btn--submit { + background-color: var(--color-primary); + color: #fff; +} + +.transfer-form__btn--submit:hover:not(:disabled) { + background-color: var(--color-primary-hover); +} + +/* Responsive styles */ +@media (max-width: 480px) { + .transfer-form { + padding: 1rem; + } + + .transfer-form__preview { + flex-direction: column; + gap: 0.75rem; + } + + .transfer-form__preview-arrow { + transform: rotate(90deg); + } + + .transfer-form__actions { + flex-direction: column-reverse; + } +} diff --git a/copy/src/components/account/TransferForm/TransferForm.tsx b/copy/src/components/account/TransferForm/TransferForm.tsx new file mode 100644 index 0000000..d3b6ead --- /dev/null +++ b/copy/src/components/account/TransferForm/TransferForm.tsx @@ -0,0 +1,277 @@ +/** + * TransferForm Component + * Form for transferring money between accounts + */ + +import React, { useState, useEffect } from 'react'; +import type { Account, TransferFormInput } from '../../../types'; +import { formatCurrency } from '../../../utils/format'; +import './TransferForm.css'; + +interface TransferFormProps { + accounts: Account[]; + onSubmit: (data: TransferFormInput) => void; + onCancel: () => void; + loading?: boolean; + initialFromAccountId?: number; +} + +export const TransferForm: React.FC = ({ + accounts, + onSubmit, + onCancel, + loading = false, + initialFromAccountId, +}) => { + const [formData, setFormData] = useState({ + fromAccountId: initialFromAccountId || 0, + toAccountId: 0, + amount: 0, + note: '', + }); + + const [errors, setErrors] = useState>>({}); + + // Set initial from account + useEffect(() => { + if (initialFromAccountId) { + setFormData((prev) => ({ ...prev, fromAccountId: initialFromAccountId })); + } else if (accounts.length > 0 && formData.fromAccountId === 0) { + setFormData((prev) => ({ ...prev, fromAccountId: accounts[0].id })); + } + }, [accounts, initialFromAccountId]); + + const fromAccount = accounts.find((a) => a.id === formData.fromAccountId); + const toAccount = accounts.find((a) => a.id === formData.toAccountId); + + // Filter available target accounts (exclude source account) + const availableToAccounts = accounts.filter((a) => a.id !== formData.fromAccountId); + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + + setFormData((prev) => ({ + ...prev, + [name]: + name === 'amount' || name === 'fromAccountId' || name === 'toAccountId' + ? Number(value) + : value, + })); + + // Clear error when field is modified + if (errors[name as keyof TransferFormInput]) { + setErrors((prev) => ({ ...prev, [name]: undefined })); + } + + // Reset toAccountId if it equals the new fromAccountId + if (name === 'fromAccountId' && Number(value) === formData.toAccountId) { + setFormData((prev) => ({ ...prev, toAccountId: 0 })); + } + }; + + const swapAccounts = () => { + if (formData.toAccountId !== 0) { + setFormData((prev) => ({ + ...prev, + fromAccountId: prev.toAccountId, + toAccountId: prev.fromAccountId, + })); + } + }; + + const validate = (): boolean => { + const newErrors: Partial> = {}; + + if (!formData.fromAccountId) { + newErrors.fromAccountId = '请选择转出账户'; + } + + if (!formData.toAccountId) { + newErrors.toAccountId = '请选择转入账户'; + } + + if (formData.fromAccountId === formData.toAccountId) { + newErrors.toAccountId = '转入账户不能与转出账户相同'; + } + + if (!formData.amount || formData.amount <= 0) { + newErrors.amount = '请输入有效的转账金额'; + } + + // Check if source account has sufficient balance (for non-credit accounts) + if (fromAccount && !fromAccount.isCredit && formData.amount > fromAccount.balance) { + newErrors.amount = `余额不足,当前余额: ${formatCurrency(fromAccount.balance, fromAccount.currency)}`; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (validate()) { + onSubmit(formData); + } + }; + + return ( +
+

账户转账

+ + {/* From Account */} +
+ + + {errors.fromAccountId && ( + {errors.fromAccountId} + )} + {fromAccount && ( + + 可用余额: {formatCurrency(fromAccount.balance, fromAccount.currency)} + + )} +
+ + {/* Swap Button */} +
+ +
+ + {/* To Account */} +
+ + + {errors.toAccountId && {errors.toAccountId}} + {toAccount && ( + + 当前余额: {formatCurrency(toAccount.balance, toAccount.currency)} + + )} +
+ + {/* Amount */} +
+ +
+ + {fromAccount?.currency === 'CNY' ? '¥' : fromAccount?.currency || '¥'} + + +
+ {errors.amount && {errors.amount}} +
+ + {/* Note */} +
+ +