feat: Spanish localization (#74)
## Pending - [x] Translate landing page - [x] Dashboard - [x] Accounts list page - [x] Account page - [x] Cashflow - [x] Budgets - [x] Transactions - [x] Settings ## Screenshots <img width="1210" height="969" alt="image" src="https://github.com/user-attachments/assets/c7935e5c-488d-4941-8f19-8834e5668257" /> <img width="1211" height="972" alt="image" src="https://github.com/user-attachments/assets/e94e1daf-233a-4a49-aa65-5678c772d178" />
This commit is contained in:
parent
d9fd01a3cf
commit
70b603e901
|
|
@ -0,0 +1,273 @@
|
||||||
|
# Frontend Localization Guide
|
||||||
|
|
||||||
|
This document describes the localization system for the Whisper Money React frontend and the automated tools available.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The application now supports multilingual UI text using a simple translation system:
|
||||||
|
|
||||||
|
- **Hook**: `useTranslations()` (aliased as `__()`)
|
||||||
|
- **Translation Files**: `lang/es.json` (Spanish), with English as fallback
|
||||||
|
- **Coverage**: 118+ React components have been localized
|
||||||
|
- **Total Strings**: 730+ translatable strings
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Backend (Laravel)
|
||||||
|
|
||||||
|
The `HandleInertiaRequests` middleware loads the appropriate translation file (`lang/{locale}.json`) and shares it with all Inertia page props:
|
||||||
|
|
||||||
|
```php
|
||||||
|
protected function getTranslations(): array
|
||||||
|
{
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
$translationFile = lang_path("{$locale}.json");
|
||||||
|
return json_decode(file_get_contents($translationFile), true) ?? [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (React)
|
||||||
|
|
||||||
|
Components use the `__()` function directly to translate strings:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
|
|
||||||
|
export default function MyComponent() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{__('Welcome to Whisper Money')}</h1>
|
||||||
|
<Button>{__('Save')}</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The `__()` function uses React's `usePage()` hook internally, so it must be called within React components.
|
||||||
|
|
||||||
|
### Frontend (React)
|
||||||
|
|
||||||
|
Components use the `__()` hook to translate strings:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
|
|
||||||
|
export default function MyComponent() {
|
||||||
|
const t = __();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{t('Welcome to Whisper Money')}</h1>
|
||||||
|
<Button>{t('Save')}</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Automated Localization Tools
|
||||||
|
|
||||||
|
### Main Localization Script (Local Development Only)
|
||||||
|
|
||||||
|
**File**: `scripts/localize-frontend.mjs` (not committed to repo)
|
||||||
|
|
||||||
|
This script automatically wraps hardcoded strings with the `t()` translation function across all React components.
|
||||||
|
|
||||||
|
**What it does**:
|
||||||
|
|
||||||
|
- Scans all `.tsx` and `.ts` files in `resources/js/`
|
||||||
|
- Wraps JSX text content and specific props (title, placeholder, etc.) with `t()`
|
||||||
|
- Auto-imports the `__()` hook and adds `const t = __()` to components
|
||||||
|
- Extracts all unique strings to `lang/es.json`
|
||||||
|
- Generates a detailed report of changes
|
||||||
|
|
||||||
|
**What it skips**:
|
||||||
|
|
||||||
|
- Technical identifiers (class names, data-test attributes, etc.)
|
||||||
|
- URLs, email addresses
|
||||||
|
- Currency codes (USD, EUR, etc.)
|
||||||
|
- Brand names (Whisper Money, Discord, etc.)
|
||||||
|
- Single-character strings
|
||||||
|
- Code-like patterns
|
||||||
|
|
||||||
|
**Usage** (local development only):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the localization script (safe to run multiple times)
|
||||||
|
node scripts/localize-frontend.mjs
|
||||||
|
|
||||||
|
# Format the modified files
|
||||||
|
bun run format
|
||||||
|
|
||||||
|
# Review changes
|
||||||
|
git diff resources/js/
|
||||||
|
git diff lang/es.json
|
||||||
|
cat localization-report.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The script is for local development only and not committed to the repository. Translations must be added manually to `lang/es.json`.
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
### Completed (✅)
|
||||||
|
|
||||||
|
- ✅ 118 React components localized
|
||||||
|
- ✅ 730 strings extracted and wrapped with `t()`
|
||||||
|
- ✅ Translation infrastructure in place
|
||||||
|
- ✅ Automated extraction tool created
|
||||||
|
- ✅ English fallbacks for all strings
|
||||||
|
|
||||||
|
### Pending (⏳)
|
||||||
|
|
||||||
|
- ⏳ Spanish translations (currently English placeholders - manual translation needed)
|
||||||
|
- ⏳ Some files may need manual review for complex JSX patterns
|
||||||
|
|
||||||
|
## Adding New Translatable Strings
|
||||||
|
|
||||||
|
### Option 1: Manual (Recommended for new code)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
|
|
||||||
|
export default function NewComponent() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{__('My New Title')}</h1>
|
||||||
|
<Input placeholder={__('Enter your name')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the translations to `lang/es.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"My New Title": "Mi Nuevo Título",
|
||||||
|
"Enter your name": "Ingresa tu nombre"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the translations to `lang/es.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"My New Title": "Mi Nuevo Título",
|
||||||
|
"Enter your name": "Ingresa tu nombre"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Automated (For bulk updates - local development)
|
||||||
|
|
||||||
|
1. Write your component with hardcoded English strings
|
||||||
|
2. Run `node scripts/localize-frontend.mjs` (if available locally)
|
||||||
|
3. Run `bun run format`
|
||||||
|
4. Manually translate new entries in `lang/es.json`
|
||||||
|
|
||||||
|
## Translation with Placeholders
|
||||||
|
|
||||||
|
Support for dynamic values:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// In your component
|
||||||
|
const message = __('Hello :name, you have :count unread messages', {
|
||||||
|
name: user.name,
|
||||||
|
count: unreadCount
|
||||||
|
});
|
||||||
|
|
||||||
|
// In lang/es.json
|
||||||
|
{
|
||||||
|
"Hello :name, you have :count unread messages": "Hola :name, tienes :count mensajes sin leer"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Translations
|
||||||
|
|
||||||
|
1. **Change language**: Go to Settings → Language → Select "Español"
|
||||||
|
2. **Test UI**: Navigate through the app and verify translations
|
||||||
|
3. **Check console**: Look for missing translation warnings (if implemented)
|
||||||
|
|
||||||
|
## Translation Guidelines
|
||||||
|
|
||||||
|
### For Spanish (es)
|
||||||
|
|
||||||
|
- Use informal "tú" form (not formal "usted")
|
||||||
|
- Financial terms should be accurate and professional
|
||||||
|
- Preserve brand names in English
|
||||||
|
- Keep the friendly, approachable tone
|
||||||
|
- Use sentence case, not title case
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
- ✅ "Guardar cambios" (Save changes)
|
||||||
|
- ❌ "Guardar Cambios" (wrong capitalization)
|
||||||
|
- ✅ "Iniciar sesión" (Log in)
|
||||||
|
- ❌ "Entrar" (too casual)
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
Consider adding translation validation to your CI pipeline in the future to ensure all strings are translated.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Issue: Translations not showing
|
||||||
|
|
||||||
|
**Solution**: Check that:
|
||||||
|
|
||||||
|
1. The string exists in `lang/es.json`
|
||||||
|
2. User's language setting is "Español" or auto-detected to Spanish
|
||||||
|
3. Browser cache is cleared
|
||||||
|
|
||||||
|
### Issue: String wrapped incorrectly by script
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
|
||||||
|
1. Revert the file: `git checkout <file>`
|
||||||
|
2. Manually wrap the string
|
||||||
|
3. Add the string pattern to skip list in `scripts/localize-frontend.mjs`
|
||||||
|
|
||||||
|
### Issue: Script breaks complex JSX
|
||||||
|
|
||||||
|
**Solution**: The script has limitations with very complex nested JSX. For these files:
|
||||||
|
|
||||||
|
1. Revert the auto-changes
|
||||||
|
2. Manually localize
|
||||||
|
3. Or simplify the JSX structure
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lang/
|
||||||
|
├── es.json # Spanish translations
|
||||||
|
├── es/ # Laravel backend translations (PHP)
|
||||||
|
│ ├── auth.php
|
||||||
|
│ ├── pagination.php
|
||||||
|
│ └── ...
|
||||||
|
└── en/ # English backend translations
|
||||||
|
|
||||||
|
scripts/
|
||||||
|
├── localize-frontend.mjs # Main localization tool
|
||||||
|
└── translate-to-spanish.mjs # AI translation helper
|
||||||
|
|
||||||
|
resources/js/
|
||||||
|
├── utils/i18n.ts # Translation hook
|
||||||
|
└── types/index.d.ts # SharedData type (includes translations)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Manual translation**: Translate English placeholders in `lang/es.json` to Spanish
|
||||||
|
2. **Review translations**: Check accuracy, tone, and proper Spanish conventions
|
||||||
|
3. **Test thoroughly**: Test the entire app in Spanish
|
||||||
|
4. **Add more languages**: Create `lang/fr.json`, `lang/de.json`, etc.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- Translation hook: `resources/js/utils/i18n.ts`
|
||||||
|
- Example localized component: `resources/js/pages/auth/login.tsx`
|
||||||
|
- Example localized settings: `resources/js/pages/settings/account.tsx`
|
||||||
|
- Translation middleware: `app/Http/Middleware/HandleInertiaRequests.php`
|
||||||
|
- Spanish translations: `lang/es.json`
|
||||||
|
|
||||||
|
## Development Scripts
|
||||||
|
|
||||||
|
The localization automation script (`scripts/localize-frontend.mjs`) is available for local development only and is not committed to the repository. It uses Babel to parse and transform React components automatically. If you need to regenerate it, refer to the project's localization implementation history.
|
||||||
|
|
@ -11,7 +11,8 @@ class CreateDefaultCategories
|
||||||
*/
|
*/
|
||||||
public function handle(User $user): void
|
public function handle(User $user): void
|
||||||
{
|
{
|
||||||
$defaultCategories = self::getDefaultCategories();
|
$locale = $user->locale ?? app()->getLocale();
|
||||||
|
$defaultCategories = self::getDefaultCategories($locale);
|
||||||
|
|
||||||
foreach ($defaultCategories as $category) {
|
foreach ($defaultCategories as $category) {
|
||||||
$user->categories()->firstOrCreate(
|
$user->categories()->firstOrCreate(
|
||||||
|
|
@ -22,11 +23,33 @@ class CreateDefaultCategories
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the default categories configuration.
|
* Get the default categories configuration for a given locale.
|
||||||
*
|
*
|
||||||
* @return array<int, array{name: string, icon: string, color: string, type: string}>
|
* @return array<int, array{name: string, icon: string, color: string, type: string}>
|
||||||
*/
|
*/
|
||||||
public static function getDefaultCategories(): array
|
public static function getDefaultCategories(string $locale = 'en'): array
|
||||||
|
{
|
||||||
|
$categories = self::getBaseCategories();
|
||||||
|
|
||||||
|
if ($locale === 'es') {
|
||||||
|
$translations = self::getSpanishTranslations();
|
||||||
|
|
||||||
|
return array_map(function (array $category) use ($translations) {
|
||||||
|
$category['name'] = $translations[$category['name']] ?? $category['name'];
|
||||||
|
|
||||||
|
return $category;
|
||||||
|
}, $categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base (English) categories configuration.
|
||||||
|
*
|
||||||
|
* @return array<int, array{name: string, icon: string, color: string, type: string}>
|
||||||
|
*/
|
||||||
|
private static function getBaseCategories(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
|
|
@ -409,4 +432,78 @@ class CreateDefaultCategories
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Spanish translations for category names.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function getSpanishTranslations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Food' => 'Alimentación',
|
||||||
|
'Cafes, restaurants, bars' => 'Cafeterías, restaurantes, bares',
|
||||||
|
'Groceries' => 'Supermercado',
|
||||||
|
'Tobacco and alcohol' => 'Tabaco y alcohol',
|
||||||
|
'Other groceries' => 'Otras compras de alimentación',
|
||||||
|
'Food delivery' => 'Comida a domicilio',
|
||||||
|
'Utility services' => 'Servicios del hogar',
|
||||||
|
'Electricity' => 'Electricidad',
|
||||||
|
'Natural gas' => 'Gas natural',
|
||||||
|
'Rent and maintanence' => 'Alquiler y mantenimiento',
|
||||||
|
'Telephone, internet, TV, computer' => 'Teléfono, internet, TV, ordenador',
|
||||||
|
'Water' => 'Agua',
|
||||||
|
'Other utility expenses' => 'Otros gastos del hogar',
|
||||||
|
'Household goods' => 'Artículos del hogar',
|
||||||
|
'Transportation' => 'Transporte',
|
||||||
|
'Parking' => 'Aparcamiento',
|
||||||
|
'Fuel' => 'Combustible',
|
||||||
|
'Transportation expenses' => 'Gastos de transporte',
|
||||||
|
'Vehicle purchase, maintenance' => 'Compra y mantenimiento de vehículo',
|
||||||
|
'Clothing and shoes' => 'Ropa y calzado',
|
||||||
|
'Leisure activities, traveling' => 'Ocio y viajes',
|
||||||
|
'Gifts' => 'Regalos',
|
||||||
|
'Books, newspapers, magazines' => 'Libros, periódicos, revistas',
|
||||||
|
'Accommodation, travel expenses' => 'Alojamiento y gastos de viaje',
|
||||||
|
'Sport and sports goods' => 'Deporte y artículos deportivos',
|
||||||
|
'Theatre, music, cinema' => 'Teatro, música, cine',
|
||||||
|
'Hobbies and other leisure time activites' => 'Hobbies y otras actividades de ocio',
|
||||||
|
'Education, health and beauty' => 'Educación, salud y belleza',
|
||||||
|
'Education and courses' => 'Educación y cursos',
|
||||||
|
'Beauty, cosmetics' => 'Belleza y cosmética',
|
||||||
|
'Health and pharmaceuticals' => 'Salud y farmacia',
|
||||||
|
'Online transactions' => 'Transacciones en línea',
|
||||||
|
'Online services' => 'Servicios en línea',
|
||||||
|
'Insurance' => 'Seguros',
|
||||||
|
'Investments' => 'Inversiones',
|
||||||
|
'Savings' => 'Ahorros',
|
||||||
|
'Other investments' => 'Otras inversiones',
|
||||||
|
'Financial services and commission' => 'Servicios financieros y comisiones',
|
||||||
|
'Fines' => 'Multas',
|
||||||
|
'Mortgage' => 'Hipoteca',
|
||||||
|
'Credit card repayment' => 'Pago de tarjeta de crédito',
|
||||||
|
'Cash withdrawal' => 'Retiro de efectivo',
|
||||||
|
'Gambling' => 'Apuestas',
|
||||||
|
'Lottery' => 'Lotería',
|
||||||
|
'Taxes and government fees' => 'Impuestos y tasas',
|
||||||
|
'Invoices' => 'Facturas',
|
||||||
|
'Personal transfers' => 'Transferencias personales',
|
||||||
|
'Other personal transfers' => 'Otras transferencias personales',
|
||||||
|
'Administrative violations' => 'Infracciones administrativas',
|
||||||
|
'Other transfers' => 'Otras transferencias',
|
||||||
|
'Other payments' => 'Otros pagos',
|
||||||
|
'Salary' => 'Salario',
|
||||||
|
'Regular income' => 'Ingresos regulares',
|
||||||
|
'Work on demand' => 'Trabajo por encargo',
|
||||||
|
'Income from rent' => 'Ingresos por alquiler',
|
||||||
|
'Unemployment benefit' => 'Prestación por desempleo',
|
||||||
|
'Tax return' => 'Devolución de impuestos',
|
||||||
|
'Return debit' => 'Devolución de débito',
|
||||||
|
'Own account' => 'Cuenta propia',
|
||||||
|
'From account of relatives' => 'Desde cuenta de familiares',
|
||||||
|
'Returned payments' => 'Pagos devueltos',
|
||||||
|
'Credit cards' => 'Tarjetas de crédito',
|
||||||
|
'Other incoming payments' => 'Otros ingresos',
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
'email' => $input['email'],
|
'email' => $input['email'],
|
||||||
'password' => $input['password'],
|
'password' => $input['password'],
|
||||||
|
'locale' => $this->detectLocaleFromRequest(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! config('mail.email_verification_enabled')) {
|
if (! config('mail.email_verification_enabled')) {
|
||||||
|
|
@ -42,4 +43,19 @@ class CreateNewUser implements CreatesNewUsers
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect locale from Accept-Language header.
|
||||||
|
*/
|
||||||
|
protected function detectLocaleFromRequest(): string
|
||||||
|
{
|
||||||
|
$acceptLanguage = request()->header('Accept-Language', '');
|
||||||
|
|
||||||
|
// Check if Spanish is preferred
|
||||||
|
if (preg_match('/^es(-|,|;)/i', $acceptLanguage) || $acceptLanguage === 'es') {
|
||||||
|
return 'es';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,9 @@ class ProfileController extends Controller
|
||||||
*/
|
*/
|
||||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$request->user()->fill($request->validated());
|
$data = $request->validated();
|
||||||
|
|
||||||
|
$request->user()->fill($data);
|
||||||
|
|
||||||
if ($request->user()->isDirty('email')) {
|
if ($request->user()->isDirty('email')) {
|
||||||
$request->user()->email_verified_at = null;
|
$request->user()->email_verified_at = null;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class BlockDemoAccountActions
|
||||||
|
|
||||||
public function handle(Request $request, Closure $next, ?string $mode = null): Response
|
public function handle(Request $request, Closure $next, ?string $mode = null): Response
|
||||||
{
|
{
|
||||||
if (! $request->user()?->isDemoAccount()) {
|
if (! $request->user()?->isDemoAccount() || app()->environment('local')) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ class HandleInertiaRequests extends Middleware
|
||||||
[$message, $author] = str(Inspiring::quotes()->random())->explode('-');
|
[$message, $author] = str(Inspiring::quotes()->random())->explode('-');
|
||||||
|
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$isDemoAccount = $user?->isDemoAccount() ?? false;
|
$isDemoAccount = $user?->isDemoAccount() && ! app()->environment('local') ?? false;
|
||||||
$isDemoQuery = $request->query('demo') === '1';
|
$isDemoQuery = $request->query('demo') === '1';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
@ -94,6 +94,25 @@ class HandleInertiaRequests extends Middleware
|
||||||
'labels' => fn () => $user ? $user->labels()
|
'labels' => fn () => $user ? $user->labels()
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(['id', 'name', 'color']) : [],
|
->get(['id', 'name', 'color']) : [],
|
||||||
|
'locale' => app()->getLocale(),
|
||||||
|
'translations' => $this->getTranslations(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all translations for the current locale as a flat key-value array.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function getTranslations(): array
|
||||||
|
{
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
$translationFile = lang_path("{$locale}.json");
|
||||||
|
|
||||||
|
if (! file_exists($translationFile)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_decode(file_get_contents($translationFile), true) ?? [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class SetLocale
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$locale = $this->determineLocale($request);
|
||||||
|
|
||||||
|
App::setLocale($locale);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the locale for the current request.
|
||||||
|
*/
|
||||||
|
protected function determineLocale(Request $request): string
|
||||||
|
{
|
||||||
|
// Priority 1: Check for lang query parameter (user override on welcome page)
|
||||||
|
if ($request->has('lang') && in_array($request->get('lang'), ['en', 'es'])) {
|
||||||
|
$locale = $request->get('lang');
|
||||||
|
// Store in session so subsequent requests remember this choice
|
||||||
|
$request->session()->put('locale', $locale);
|
||||||
|
|
||||||
|
return $locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Check authenticated user's locale preference
|
||||||
|
if ($request->user() && $request->user()->locale) {
|
||||||
|
return $request->user()->locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2b: Authenticated user without locale — detect and persist
|
||||||
|
if ($request->user()) {
|
||||||
|
$detected = $this->detectLocaleFromHeader($request);
|
||||||
|
|
||||||
|
if (in_array($request->session()->get('locale'), ['en', 'es'])) {
|
||||||
|
$detected = $request->session()->get('locale');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->update(['locale' => $detected]);
|
||||||
|
|
||||||
|
return $detected;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Check session for previously detected locale
|
||||||
|
if ($request->session()->has('locale')) {
|
||||||
|
return $request->session()->get('locale');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 4: Detect from Accept-Language header
|
||||||
|
$detected = $this->detectLocaleFromHeader($request);
|
||||||
|
|
||||||
|
// Store in session for subsequent requests
|
||||||
|
$request->session()->put('locale', $detected);
|
||||||
|
|
||||||
|
return $detected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect locale from Accept-Language header.
|
||||||
|
*/
|
||||||
|
protected function detectLocaleFromHeader(Request $request): string
|
||||||
|
{
|
||||||
|
$acceptLanguage = $request->header('Accept-Language', '');
|
||||||
|
|
||||||
|
// Check if Spanish is preferred
|
||||||
|
if (preg_match('/^es(-|,|;)/i', $acceptLanguage) || $acceptLanguage === 'es') {
|
||||||
|
return 'es';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ class ProfileUpdateRequest extends FormRequest
|
||||||
Rule::unique(User::class)->ignore($this->user()->id),
|
Rule::unique(User::class)->ignore($this->user()->id),
|
||||||
],
|
],
|
||||||
'currency_code' => ['required', 'string', 'max:3', Rule::in(['USD', 'EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'CNY', 'INR', 'MXN'])],
|
'currency_code' => ['required', 'string', 'max:3', Rule::in(['USD', 'EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'CNY', 'INR', 'MXN'])],
|
||||||
|
'locale' => ['nullable', 'string', Rule::in(['en', 'es'])],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class FeedbackEmail extends Mailable implements ShouldQueue
|
||||||
public function envelope(): Envelope
|
public function envelope(): Envelope
|
||||||
{
|
{
|
||||||
return new Envelope(
|
return new Envelope(
|
||||||
subject: "How's Your Experience So Far?",
|
subject: __("How's Your Experience So Far?"),
|
||||||
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class ImportHelpEmail extends Mailable implements ShouldQueue
|
||||||
public function envelope(): Envelope
|
public function envelope(): Envelope
|
||||||
{
|
{
|
||||||
return new Envelope(
|
return new Envelope(
|
||||||
subject: "Let's Import Your Transactions",
|
subject: __("Let's Import Your Transactions"),
|
||||||
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class OnboardingReminderEmail extends Mailable implements ShouldQueue
|
||||||
public function envelope(): Envelope
|
public function envelope(): Envelope
|
||||||
{
|
{
|
||||||
return new Envelope(
|
return new Envelope(
|
||||||
subject: 'Need Help Getting Started?',
|
subject: __('Need Help Getting Started?'),
|
||||||
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class PromoCodeEmail extends Mailable implements ShouldQueue
|
||||||
public function envelope(): Envelope
|
public function envelope(): Envelope
|
||||||
{
|
{
|
||||||
return (new Envelope(
|
return (new Envelope(
|
||||||
subject: 'Your Founder Discount - 80% Off First Period'
|
subject: __('Your Founder Discount - 80% Off First Period')
|
||||||
))->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
))->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class SubscriptionCancelledEmail extends Mailable implements ShouldQueue
|
||||||
public function envelope(): Envelope
|
public function envelope(): Envelope
|
||||||
{
|
{
|
||||||
return new Envelope(
|
return new Envelope(
|
||||||
subject: "We're sorry to see you go",
|
subject: __("We're sorry to see you go"),
|
||||||
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class WelcomeEmail extends Mailable implements ShouldQueue
|
||||||
public function envelope(): Envelope
|
public function envelope(): Envelope
|
||||||
{
|
{
|
||||||
return new Envelope(
|
return new Envelope(
|
||||||
subject: 'Welcome to Whisper Money - Your Privacy-First Finance App',
|
subject: __('Welcome to Whisper Money - Your Privacy-First Finance App'),
|
||||||
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Models;
|
||||||
use App\Enums\DripEmailType;
|
use App\Enums\DripEmailType;
|
||||||
use App\Notifications\VerifyEmailNotification;
|
use App\Notifications\VerifyEmailNotification;
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Contracts\Translation\HasLocalePreference;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
@ -16,7 +17,7 @@ use Laravel\Cashier\Billable;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
use Laravel\Pennant\Concerns\HasFeatures;
|
use Laravel\Pennant\Concerns\HasFeatures;
|
||||||
|
|
||||||
class User extends Authenticatable implements MustVerifyEmail
|
class User extends Authenticatable implements HasLocalePreference, MustVerifyEmail
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use Billable, HasFactory, HasFeatures, HasUuids, Notifiable, TwoFactorAuthenticatable;
|
use Billable, HasFactory, HasFeatures, HasUuids, Notifiable, TwoFactorAuthenticatable;
|
||||||
|
|
@ -33,6 +34,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||||
'encryption_salt',
|
'encryption_salt',
|
||||||
'onboarded_at',
|
'onboarded_at',
|
||||||
'currency_code',
|
'currency_code',
|
||||||
|
'locale',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -135,6 +137,11 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||||
return $this->email === config('app.demo.email');
|
return $this->email === config('app.demo.email');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function preferredLocale(): string
|
||||||
|
{
|
||||||
|
return $this->locale ?? 'en';
|
||||||
|
}
|
||||||
|
|
||||||
public function sendEmailVerificationNotification(): void
|
public function sendEmailVerificationNotification(): void
|
||||||
{
|
{
|
||||||
$this->notify(new VerifyEmailNotification);
|
$this->notify(new VerifyEmailNotification);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ class VerifyEmailNotification extends VerifyEmail
|
||||||
|
|
||||||
return (new MailMessage)
|
return (new MailMessage)
|
||||||
->from(config('mail.from.address', 'hello@example.com'), 'Victor')
|
->from(config('mail.from.address', 'hello@example.com'), 'Victor')
|
||||||
->subject('Verify Your Email - Whisper Money')
|
->subject(__('Verify Your Email - Whisper Money'))
|
||||||
->markdown('mail.verify-email', [
|
->markdown('mail.verify-email', [
|
||||||
'userName' => $notifiable->name,
|
'userName' => $notifiable->name,
|
||||||
'verificationUrl' => $verificationUrl,
|
'verificationUrl' => $verificationUrl,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use App\Http\Middleware\EnsureBudgetsFeature;
|
||||||
use App\Http\Middleware\EnsureUserIsSubscribed;
|
use App\Http\Middleware\EnsureUserIsSubscribed;
|
||||||
use App\Http\Middleware\HandleAppearance;
|
use App\Http\Middleware\HandleAppearance;
|
||||||
use App\Http\Middleware\HandleInertiaRequests;
|
use App\Http\Middleware\HandleInertiaRequests;
|
||||||
|
use App\Http\Middleware\SetLocale;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
|
@ -30,6 +31,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||||
|
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
HandleAppearance::class,
|
HandleAppearance::class,
|
||||||
|
SetLocale::class,
|
||||||
HandleInertiaRequests::class,
|
HandleInertiaRequests::class,
|
||||||
AddLinkHeadersForPreloadedAssets::class,
|
AddLinkHeadersForPreloadedAssets::class,
|
||||||
\App\Http\Middleware\BlockDemoAccountActions::class.':auto',
|
\App\Http\Middleware\BlockDemoAccountActions::class.':auto',
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ class UserFactory extends Factory
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
'onboarded_at' => now(),
|
'onboarded_at' => now(),
|
||||||
'encryption_salt' => 'test-salt',
|
'encryption_salt' => 'test-salt',
|
||||||
|
'locale' => 'en',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('locale', 2)->nullable()->after('currency_code');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('locale');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used during authentication for various
|
||||||
|
| messages that we need to display to the user. You are free to modify
|
||||||
|
| these language lines according to your application's requirements.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'failed' => 'These credentials do not match our records.',
|
||||||
|
'password' => 'The provided password is incorrect.',
|
||||||
|
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pagination Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used by the paginator library to build
|
||||||
|
| the simple pagination links. You are free to change them to anything
|
||||||
|
| you want to customize your views to better match your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'previous' => '« Previous',
|
||||||
|
'next' => 'Next »',
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Password Reset Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are the default lines which match reasons
|
||||||
|
| that are given by the password broker for a password update attempt
|
||||||
|
| outcome such as failure due to an invalid password / reset token.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'reset' => 'Your password has been reset.',
|
||||||
|
'sent' => 'We have emailed your password reset link.',
|
||||||
|
'throttled' => 'Please wait before retrying.',
|
||||||
|
'token' => 'This password reset token is invalid.',
|
||||||
|
'user' => "We can't find a user with that email address.",
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Validation Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines contain the default error messages used by
|
||||||
|
| the validator class. Some of these rules have multiple versions such
|
||||||
|
| as the size rules. Feel free to tweak each of these messages here.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'accepted' => 'The :attribute field must be accepted.',
|
||||||
|
'accepted_if' => 'The :attribute field must be accepted when :other is :value.',
|
||||||
|
'active_url' => 'The :attribute field must be a valid URL.',
|
||||||
|
'after' => 'The :attribute field must be a date after :date.',
|
||||||
|
'after_or_equal' => 'The :attribute field must be a date after or equal to :date.',
|
||||||
|
'alpha' => 'The :attribute field must only contain letters.',
|
||||||
|
'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.',
|
||||||
|
'alpha_num' => 'The :attribute field must only contain letters and numbers.',
|
||||||
|
'any_of' => 'The :attribute field is invalid.',
|
||||||
|
'array' => 'The :attribute field must be an array.',
|
||||||
|
'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.',
|
||||||
|
'before' => 'The :attribute field must be a date before :date.',
|
||||||
|
'before_or_equal' => 'The :attribute field must be a date before or equal to :date.',
|
||||||
|
'between' => [
|
||||||
|
'array' => 'The :attribute field must have between :min and :max items.',
|
||||||
|
'file' => 'The :attribute field must be between :min and :max kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be between :min and :max.',
|
||||||
|
'string' => 'The :attribute field must be between :min and :max characters.',
|
||||||
|
],
|
||||||
|
'boolean' => 'The :attribute field must be true or false.',
|
||||||
|
'can' => 'The :attribute field contains an unauthorized value.',
|
||||||
|
'confirmed' => 'The :attribute field confirmation does not match.',
|
||||||
|
'contains' => 'The :attribute field is missing a required value.',
|
||||||
|
'current_password' => 'The password is incorrect.',
|
||||||
|
'date' => 'The :attribute field must be a valid date.',
|
||||||
|
'date_equals' => 'The :attribute field must be a date equal to :date.',
|
||||||
|
'date_format' => 'The :attribute field must match the format :format.',
|
||||||
|
'decimal' => 'The :attribute field must have :decimal decimal places.',
|
||||||
|
'declined' => 'The :attribute field must be declined.',
|
||||||
|
'declined_if' => 'The :attribute field must be declined when :other is :value.',
|
||||||
|
'different' => 'The :attribute field and :other must be different.',
|
||||||
|
'digits' => 'The :attribute field must be :digits digits.',
|
||||||
|
'digits_between' => 'The :attribute field must be between :min and :max digits.',
|
||||||
|
'dimensions' => 'The :attribute field has invalid image dimensions.',
|
||||||
|
'distinct' => 'The :attribute field has a duplicate value.',
|
||||||
|
'doesnt_contain' => 'The :attribute field must not contain any of the following: :values.',
|
||||||
|
'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.',
|
||||||
|
'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.',
|
||||||
|
'email' => 'The :attribute field must be a valid email address.',
|
||||||
|
'encoding' => 'The :attribute field must be encoded in :encoding.',
|
||||||
|
'ends_with' => 'The :attribute field must end with one of the following: :values.',
|
||||||
|
'enum' => 'The selected :attribute is invalid.',
|
||||||
|
'exists' => 'The selected :attribute is invalid.',
|
||||||
|
'extensions' => 'The :attribute field must have one of the following extensions: :values.',
|
||||||
|
'file' => 'The :attribute field must be a file.',
|
||||||
|
'filled' => 'The :attribute field must have a value.',
|
||||||
|
'gt' => [
|
||||||
|
'array' => 'The :attribute field must have more than :value items.',
|
||||||
|
'file' => 'The :attribute field must be greater than :value kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be greater than :value.',
|
||||||
|
'string' => 'The :attribute field must be greater than :value characters.',
|
||||||
|
],
|
||||||
|
'gte' => [
|
||||||
|
'array' => 'The :attribute field must have :value items or more.',
|
||||||
|
'file' => 'The :attribute field must be greater than or equal to :value kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be greater than or equal to :value.',
|
||||||
|
'string' => 'The :attribute field must be greater than or equal to :value characters.',
|
||||||
|
],
|
||||||
|
'hex_color' => 'The :attribute field must be a valid hexadecimal color.',
|
||||||
|
'image' => 'The :attribute field must be an image.',
|
||||||
|
'in' => 'The selected :attribute is invalid.',
|
||||||
|
'in_array' => 'The :attribute field must exist in :other.',
|
||||||
|
'in_array_keys' => 'The :attribute field must contain at least one of the following keys: :values.',
|
||||||
|
'integer' => 'The :attribute field must be an integer.',
|
||||||
|
'ip' => 'The :attribute field must be a valid IP address.',
|
||||||
|
'ipv4' => 'The :attribute field must be a valid IPv4 address.',
|
||||||
|
'ipv6' => 'The :attribute field must be a valid IPv6 address.',
|
||||||
|
'json' => 'The :attribute field must be a valid JSON string.',
|
||||||
|
'list' => 'The :attribute field must be a list.',
|
||||||
|
'lowercase' => 'The :attribute field must be lowercase.',
|
||||||
|
'lt' => [
|
||||||
|
'array' => 'The :attribute field must have less than :value items.',
|
||||||
|
'file' => 'The :attribute field must be less than :value kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be less than :value.',
|
||||||
|
'string' => 'The :attribute field must be less than :value characters.',
|
||||||
|
],
|
||||||
|
'lte' => [
|
||||||
|
'array' => 'The :attribute field must not have more than :value items.',
|
||||||
|
'file' => 'The :attribute field must be less than or equal to :value kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be less than or equal to :value.',
|
||||||
|
'string' => 'The :attribute field must be less than or equal to :value characters.',
|
||||||
|
],
|
||||||
|
'mac_address' => 'The :attribute field must be a valid MAC address.',
|
||||||
|
'max' => [
|
||||||
|
'array' => 'The :attribute field must not have more than :max items.',
|
||||||
|
'file' => 'The :attribute field must not be greater than :max kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must not be greater than :max.',
|
||||||
|
'string' => 'The :attribute field must not be greater than :max characters.',
|
||||||
|
],
|
||||||
|
'max_digits' => 'The :attribute field must not have more than :max digits.',
|
||||||
|
'mimes' => 'The :attribute field must be a file of type: :values.',
|
||||||
|
'mimetypes' => 'The :attribute field must be a file of type: :values.',
|
||||||
|
'min' => [
|
||||||
|
'array' => 'The :attribute field must have at least :min items.',
|
||||||
|
'file' => 'The :attribute field must be at least :min kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be at least :min.',
|
||||||
|
'string' => 'The :attribute field must be at least :min characters.',
|
||||||
|
],
|
||||||
|
'min_digits' => 'The :attribute field must have at least :min digits.',
|
||||||
|
'missing' => 'The :attribute field must be missing.',
|
||||||
|
'missing_if' => 'The :attribute field must be missing when :other is :value.',
|
||||||
|
'missing_unless' => 'The :attribute field must be missing unless :other is :value.',
|
||||||
|
'missing_with' => 'The :attribute field must be missing when :values is present.',
|
||||||
|
'missing_with_all' => 'The :attribute field must be missing when :values are present.',
|
||||||
|
'multiple_of' => 'The :attribute field must be a multiple of :value.',
|
||||||
|
'not_in' => 'The selected :attribute is invalid.',
|
||||||
|
'not_regex' => 'The :attribute field format is invalid.',
|
||||||
|
'numeric' => 'The :attribute field must be a number.',
|
||||||
|
'password' => [
|
||||||
|
'letters' => 'The :attribute field must contain at least one letter.',
|
||||||
|
'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.',
|
||||||
|
'numbers' => 'The :attribute field must contain at least one number.',
|
||||||
|
'symbols' => 'The :attribute field must contain at least one symbol.',
|
||||||
|
'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.',
|
||||||
|
],
|
||||||
|
'present' => 'The :attribute field must be present.',
|
||||||
|
'present_if' => 'The :attribute field must be present when :other is :value.',
|
||||||
|
'present_unless' => 'The :attribute field must be present unless :other is :value.',
|
||||||
|
'present_with' => 'The :attribute field must be present when :values is present.',
|
||||||
|
'present_with_all' => 'The :attribute field must be present when :values are present.',
|
||||||
|
'prohibited' => 'The :attribute field is prohibited.',
|
||||||
|
'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',
|
||||||
|
'prohibited_if_accepted' => 'The :attribute field is prohibited when :other is accepted.',
|
||||||
|
'prohibited_if_declined' => 'The :attribute field is prohibited when :other is declined.',
|
||||||
|
'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',
|
||||||
|
'prohibits' => 'The :attribute field prohibits :other from being present.',
|
||||||
|
'regex' => 'The :attribute field format is invalid.',
|
||||||
|
'required' => 'The :attribute field is required.',
|
||||||
|
'required_array_keys' => 'The :attribute field must contain entries for: :values.',
|
||||||
|
'required_if' => 'The :attribute field is required when :other is :value.',
|
||||||
|
'required_if_accepted' => 'The :attribute field is required when :other is accepted.',
|
||||||
|
'required_if_declined' => 'The :attribute field is required when :other is declined.',
|
||||||
|
'required_unless' => 'The :attribute field is required unless :other is in :values.',
|
||||||
|
'required_with' => 'The :attribute field is required when :values is present.',
|
||||||
|
'required_with_all' => 'The :attribute field is required when :values are present.',
|
||||||
|
'required_without' => 'The :attribute field is required when :values is not present.',
|
||||||
|
'required_without_all' => 'The :attribute field is required when none of :values are present.',
|
||||||
|
'same' => 'The :attribute field must match :other.',
|
||||||
|
'size' => [
|
||||||
|
'array' => 'The :attribute field must contain :size items.',
|
||||||
|
'file' => 'The :attribute field must be :size kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be :size.',
|
||||||
|
'string' => 'The :attribute field must be :size characters.',
|
||||||
|
],
|
||||||
|
'starts_with' => 'The :attribute field must start with one of the following: :values.',
|
||||||
|
'string' => 'The :attribute field must be a string.',
|
||||||
|
'timezone' => 'The :attribute field must be a valid timezone.',
|
||||||
|
'unique' => 'The :attribute has already been taken.',
|
||||||
|
'uploaded' => 'The :attribute failed to upload.',
|
||||||
|
'uppercase' => 'The :attribute field must be uppercase.',
|
||||||
|
'url' => 'The :attribute field must be a valid URL.',
|
||||||
|
'ulid' => 'The :attribute field must be a valid ULID.',
|
||||||
|
'uuid' => 'The :attribute field must be a valid UUID.',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Custom Validation Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify custom validation messages for attributes using the
|
||||||
|
| convention "attribute.rule" to name the lines. This makes it quick to
|
||||||
|
| specify a specific custom language line for a given attribute rule.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'custom' => [
|
||||||
|
'attribute-name' => [
|
||||||
|
'rule-name' => 'custom-message',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Custom Validation Attributes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used to swap our attribute placeholder
|
||||||
|
| with something more reader friendly such as "E-Mail Address" instead
|
||||||
|
| of "email". This simply helps us make our message more expressive.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'attributes' => [],
|
||||||
|
|
||||||
|
];
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used during authentication for various
|
||||||
|
| messages that we need to display to the user. You are free to modify
|
||||||
|
| these language lines according to your application's requirements.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'failed' => 'Estas credenciales no coinciden con nuestros registros.',
|
||||||
|
'password' => 'La contraseña proporcionada es incorrecta.',
|
||||||
|
'throttle' => 'Demasiados intentos de acceso. Por favor intente de nuevo en :seconds segundos.',
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pagination Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used by the paginator library to build
|
||||||
|
| the simple pagination links. You are free to change them to anything
|
||||||
|
| you want to customize your views to better match your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'previous' => '« Anterior',
|
||||||
|
'next' => 'Siguiente »',
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Password Reset Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are the default lines which match reasons
|
||||||
|
| that are given by the password broker for a password update attempt
|
||||||
|
| outcome such as failure due to an invalid password / reset token.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'reset' => 'Tu contraseña ha sido restablecida.',
|
||||||
|
'sent' => 'Te hemos enviado por correo electrónico el enlace para restablecer tu contraseña.',
|
||||||
|
'throttled' => 'Por favor espera antes de intentar de nuevo.',
|
||||||
|
'token' => 'Este token de restablecimiento de contraseña es inválido.',
|
||||||
|
'user' => 'No podemos encontrar un usuario con esa dirección de correo electrónico.',
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Validation Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines contain the default error messages used by
|
||||||
|
| the validator class. Some of these rules have multiple versions such
|
||||||
|
| as the size rules. Feel free to tweak each of these messages here.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'accepted' => 'El campo :attribute debe ser aceptado.',
|
||||||
|
'accepted_if' => 'El campo :attribute debe ser aceptado cuando :other sea :value.',
|
||||||
|
'active_url' => 'El campo :attribute debe ser una URL válida.',
|
||||||
|
'after' => 'El campo :attribute debe ser una fecha posterior a :date.',
|
||||||
|
'after_or_equal' => 'El campo :attribute debe ser una fecha posterior o igual a :date.',
|
||||||
|
'alpha' => 'El campo :attribute solo debe contener letras.',
|
||||||
|
'alpha_dash' => 'El campo :attribute solo debe contener letras, números, guiones y guiones bajos.',
|
||||||
|
'alpha_num' => 'El campo :attribute solo debe contener letras y números.',
|
||||||
|
'any_of' => 'El campo :attribute es inválido.',
|
||||||
|
'array' => 'El campo :attribute debe ser un arreglo.',
|
||||||
|
'ascii' => 'El campo :attribute solo debe contener caracteres alfanuméricos y símbolos de un byte.',
|
||||||
|
'before' => 'El campo :attribute debe ser una fecha anterior a :date.',
|
||||||
|
'before_or_equal' => 'El campo :attribute debe ser una fecha anterior o igual a :date.',
|
||||||
|
'between' => [
|
||||||
|
'array' => 'El campo :attribute debe tener entre :min y :max elementos.',
|
||||||
|
'file' => 'El campo :attribute debe tener entre :min y :max kilobytes.',
|
||||||
|
'numeric' => 'El campo :attribute debe estar entre :min y :max.',
|
||||||
|
'string' => 'El campo :attribute debe tener entre :min y :max caracteres.',
|
||||||
|
],
|
||||||
|
'boolean' => 'El campo :attribute debe ser verdadero o falso.',
|
||||||
|
'can' => 'El campo :attribute contiene un valor no autorizado.',
|
||||||
|
'confirmed' => 'La confirmación del campo :attribute no coincide.',
|
||||||
|
'contains' => 'El campo :attribute falta un valor requerido.',
|
||||||
|
'current_password' => 'La contraseña es incorrecta.',
|
||||||
|
'date' => 'El campo :attribute debe ser una fecha válida.',
|
||||||
|
'date_equals' => 'El campo :attribute debe ser una fecha igual a :date.',
|
||||||
|
'date_format' => 'El campo :attribute debe coincidir con el formato :format.',
|
||||||
|
'decimal' => 'El campo :attribute debe tener :decimal lugares decimales.',
|
||||||
|
'declined' => 'El campo :attribute debe ser rechazado.',
|
||||||
|
'declined_if' => 'El campo :attribute debe ser rechazado cuando :other sea :value.',
|
||||||
|
'different' => 'El campo :attribute y :other deben ser diferentes.',
|
||||||
|
'digits' => 'El campo :attribute debe tener :digits dígitos.',
|
||||||
|
'digits_between' => 'El campo :attribute debe tener entre :min y :max dígitos.',
|
||||||
|
'dimensions' => 'El campo :attribute tiene dimensiones de imagen inválidas.',
|
||||||
|
'distinct' => 'El campo :attribute tiene un valor duplicado.',
|
||||||
|
'doesnt_contain' => 'El campo :attribute no debe contener ninguno de los siguientes: :values.',
|
||||||
|
'doesnt_end_with' => 'El campo :attribute no debe terminar con ninguno de los siguientes: :values.',
|
||||||
|
'doesnt_start_with' => 'El campo :attribute no debe comenzar con ninguno de los siguientes: :values.',
|
||||||
|
'email' => 'El campo :attribute debe ser una dirección de correo electrónico válida.',
|
||||||
|
'encoding' => 'El campo :attribute debe estar codificado en :encoding.',
|
||||||
|
'ends_with' => 'El campo :attribute debe terminar con uno de los siguientes: :values.',
|
||||||
|
'enum' => 'El :attribute seleccionado es inválido.',
|
||||||
|
'exists' => 'El :attribute seleccionado es inválido.',
|
||||||
|
'extensions' => 'El campo :attribute debe tener una de las siguientes extensiones: :values.',
|
||||||
|
'file' => 'El campo :attribute debe ser un archivo.',
|
||||||
|
'filled' => 'El campo :attribute debe tener un valor.',
|
||||||
|
'gt' => [
|
||||||
|
'array' => 'El campo :attribute debe tener más de :value elementos.',
|
||||||
|
'file' => 'El campo :attribute debe ser mayor que :value kilobytes.',
|
||||||
|
'numeric' => 'El campo :attribute debe ser mayor que :value.',
|
||||||
|
'string' => 'El campo :attribute debe ser mayor que :value caracteres.',
|
||||||
|
],
|
||||||
|
'gte' => [
|
||||||
|
'array' => 'El campo :attribute debe tener :value elementos o más.',
|
||||||
|
'file' => 'El campo :attribute debe ser mayor o igual a :value kilobytes.',
|
||||||
|
'numeric' => 'El campo :attribute debe ser mayor o igual a :value.',
|
||||||
|
'string' => 'El campo :attribute debe ser mayor o igual a :value caracteres.',
|
||||||
|
],
|
||||||
|
'hex_color' => 'El campo :attribute debe ser un color hexadecimal válido.',
|
||||||
|
'image' => 'El campo :attribute debe ser una imagen.',
|
||||||
|
'in' => 'El :attribute seleccionado es inválido.',
|
||||||
|
'in_array' => 'El campo :attribute debe existir en :other.',
|
||||||
|
'in_array_keys' => 'El campo :attribute debe contener al menos una de las siguientes claves: :values.',
|
||||||
|
'integer' => 'El campo :attribute debe ser un número entero.',
|
||||||
|
'ip' => 'El campo :attribute debe ser una dirección IP válida.',
|
||||||
|
'ipv4' => 'El campo :attribute debe ser una dirección IPv4 válida.',
|
||||||
|
'ipv6' => 'El campo :attribute debe ser una dirección IPv6 válida.',
|
||||||
|
'json' => 'El campo :attribute debe ser una cadena JSON válida.',
|
||||||
|
'list' => 'El campo :attribute debe ser una lista.',
|
||||||
|
'lowercase' => 'El campo :attribute debe estar en minúsculas.',
|
||||||
|
'lt' => [
|
||||||
|
'array' => 'El campo :attribute debe tener menos de :value elementos.',
|
||||||
|
'file' => 'El campo :attribute debe ser menor que :value kilobytes.',
|
||||||
|
'numeric' => 'El campo :attribute debe ser menor que :value.',
|
||||||
|
'string' => 'El campo :attribute debe ser menor que :value caracteres.',
|
||||||
|
],
|
||||||
|
'lte' => [
|
||||||
|
'array' => 'El campo :attribute no debe tener más de :value elementos.',
|
||||||
|
'file' => 'El campo :attribute debe ser menor o igual a :value kilobytes.',
|
||||||
|
'numeric' => 'El campo :attribute debe ser menor o igual a :value.',
|
||||||
|
'string' => 'El campo :attribute debe ser menor o igual a :value caracteres.',
|
||||||
|
],
|
||||||
|
'mac_address' => 'El campo :attribute debe ser una dirección MAC válida.',
|
||||||
|
'max' => [
|
||||||
|
'array' => 'El campo :attribute no debe tener más de :max elementos.',
|
||||||
|
'file' => 'El campo :attribute no debe ser mayor que :max kilobytes.',
|
||||||
|
'numeric' => 'El campo :attribute no debe ser mayor que :max.',
|
||||||
|
'string' => 'El campo :attribute no debe ser mayor que :max caracteres.',
|
||||||
|
],
|
||||||
|
'max_digits' => 'El campo :attribute no debe tener más de :max dígitos.',
|
||||||
|
'mimes' => 'El campo :attribute debe ser un archivo de tipo: :values.',
|
||||||
|
'mimetypes' => 'El campo :attribute debe ser un archivo de tipo: :values.',
|
||||||
|
'min' => [
|
||||||
|
'array' => 'El campo :attribute debe tener al menos :min elementos.',
|
||||||
|
'file' => 'El campo :attribute debe tener al menos :min kilobytes.',
|
||||||
|
'numeric' => 'El campo :attribute debe ser al menos :min.',
|
||||||
|
'string' => 'El campo :attribute debe tener al menos :min caracteres.',
|
||||||
|
],
|
||||||
|
'min_digits' => 'El campo :attribute debe tener al menos :min dígitos.',
|
||||||
|
'missing' => 'El campo :attribute debe estar ausente.',
|
||||||
|
'missing_if' => 'El campo :attribute debe estar ausente cuando :other sea :value.',
|
||||||
|
'missing_unless' => 'El campo :attribute debe estar ausente a menos que :other sea :value.',
|
||||||
|
'missing_with' => 'El campo :attribute debe estar ausente cuando :values esté presente.',
|
||||||
|
'missing_with_all' => 'El campo :attribute debe estar ausente cuando :values estén presentes.',
|
||||||
|
'multiple_of' => 'El campo :attribute debe ser un múltiplo de :value.',
|
||||||
|
'not_in' => 'El :attribute seleccionado es inválido.',
|
||||||
|
'not_regex' => 'El formato del campo :attribute es inválido.',
|
||||||
|
'numeric' => 'El campo :attribute debe ser un número.',
|
||||||
|
'password' => [
|
||||||
|
'letters' => 'El campo :attribute debe contener al menos una letra.',
|
||||||
|
'mixed' => 'El campo :attribute debe contener al menos una letra mayúscula y una minúscula.',
|
||||||
|
'numbers' => 'El campo :attribute debe contener al menos un número.',
|
||||||
|
'symbols' => 'El campo :attribute debe contener al menos un símbolo.',
|
||||||
|
'uncompromised' => 'El :attribute proporcionado ha aparecido en una filtración de datos. Por favor elige un :attribute diferente.',
|
||||||
|
],
|
||||||
|
'present' => 'El campo :attribute debe estar presente.',
|
||||||
|
'present_if' => 'El campo :attribute debe estar presente cuando :other sea :value.',
|
||||||
|
'present_unless' => 'El campo :attribute debe estar presente a menos que :other sea :value.',
|
||||||
|
'present_with' => 'El campo :attribute debe estar presente cuando :values esté presente.',
|
||||||
|
'present_with_all' => 'El campo :attribute debe estar presente cuando :values estén presentes.',
|
||||||
|
'prohibited' => 'El campo :attribute está prohibido.',
|
||||||
|
'prohibited_if' => 'El campo :attribute está prohibido cuando :other sea :value.',
|
||||||
|
'prohibited_if_accepted' => 'El campo :attribute está prohibido cuando :other sea aceptado.',
|
||||||
|
'prohibited_if_declined' => 'El campo :attribute está prohibido cuando :other sea rechazado.',
|
||||||
|
'prohibited_unless' => 'El campo :attribute está prohibido a menos que :other esté en :values.',
|
||||||
|
'prohibits' => 'El campo :attribute prohíbe que :other esté presente.',
|
||||||
|
'regex' => 'El formato del campo :attribute es inválido.',
|
||||||
|
'required' => 'El campo :attribute es obligatorio.',
|
||||||
|
'required_array_keys' => 'El campo :attribute debe contener entradas para: :values.',
|
||||||
|
'required_if' => 'El campo :attribute es obligatorio cuando :other sea :value.',
|
||||||
|
'required_if_accepted' => 'El campo :attribute es obligatorio cuando :other sea aceptado.',
|
||||||
|
'required_if_declined' => 'El campo :attribute es obligatorio cuando :other sea rechazado.',
|
||||||
|
'required_unless' => 'El campo :attribute es obligatorio a menos que :other esté en :values.',
|
||||||
|
'required_with' => 'El campo :attribute es obligatorio cuando :values esté presente.',
|
||||||
|
'required_with_all' => 'El campo :attribute es obligatorio cuando :values estén presentes.',
|
||||||
|
'required_without' => 'El campo :attribute es obligatorio cuando :values no esté presente.',
|
||||||
|
'required_without_all' => 'El campo :attribute es obligatorio cuando ninguno de :values esté presente.',
|
||||||
|
'same' => 'El campo :attribute debe coincidir con :other.',
|
||||||
|
'size' => [
|
||||||
|
'array' => 'El campo :attribute debe contener :size elementos.',
|
||||||
|
'file' => 'El campo :attribute debe tener :size kilobytes.',
|
||||||
|
'numeric' => 'El campo :attribute debe ser :size.',
|
||||||
|
'string' => 'El campo :attribute debe tener :size caracteres.',
|
||||||
|
],
|
||||||
|
'starts_with' => 'El campo :attribute debe comenzar con uno de los siguientes: :values.',
|
||||||
|
'string' => 'El campo :attribute debe ser una cadena de texto.',
|
||||||
|
'timezone' => 'El campo :attribute debe ser una zona horaria válida.',
|
||||||
|
'unique' => 'El :attribute ya ha sido tomado.',
|
||||||
|
'uploaded' => 'El :attribute falló al subirse.',
|
||||||
|
'uppercase' => 'El campo :attribute debe estar en mayúsculas.',
|
||||||
|
'url' => 'El campo :attribute debe ser una URL válida.',
|
||||||
|
'ulid' => 'El campo :attribute debe ser un ULID válido.',
|
||||||
|
'uuid' => 'El campo :attribute debe ser un UUID válido.',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Custom Validation Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify custom validation messages for attributes using the
|
||||||
|
| convention "attribute.rule" to name the lines. This makes it quick to
|
||||||
|
| specify a specific custom language line for a given attribute rule.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'custom' => [
|
||||||
|
'attribute-name' => [
|
||||||
|
'rule-name' => 'custom-message',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Custom Validation Attributes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used to swap our attribute placeholder
|
||||||
|
| with something more reader friendly such as "E-Mail Address" instead
|
||||||
|
| of "email". This simply helps us make our message more expressive.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'attributes' => [],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -33,5 +33,6 @@
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||||
|
<env name="EMAIL_VERIFICATION_ENABLED" value="true"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import '../css/app.css';
|
import '../css/app.css';
|
||||||
|
|
||||||
import { createInertiaApp } from '@inertiajs/react';
|
import { createInertiaApp, router } from '@inertiajs/react';
|
||||||
import * as Sentry from '@sentry/react';
|
import * as Sentry from '@sentry/react';
|
||||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||||
import {
|
import {
|
||||||
|
|
@ -19,6 +19,7 @@ import { SyncProvider } from './contexts/sync-context';
|
||||||
import { initializeTheme } from './hooks/use-appearance';
|
import { initializeTheme } from './hooks/use-appearance';
|
||||||
import { initializePostHog } from './lib/posthog';
|
import { initializePostHog } from './lib/posthog';
|
||||||
import type { SharedData } from './types';
|
import type { SharedData } from './types';
|
||||||
|
import { setTranslations } from './utils/i18n';
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: 'https://47f7a823afae4c2f93ab3159ca7c0a3a@bugsink.whisper.money/2',
|
dsn: 'https://47f7a823afae4c2f93ab3159ca7c0a3a@bugsink.whisper.money/2',
|
||||||
|
|
@ -56,6 +57,19 @@ createInertiaApp({
|
||||||
const initialUser = initialPageProps?.auth?.user ?? null;
|
const initialUser = initialPageProps?.auth?.user ?? null;
|
||||||
const initialIsAuthenticated = Boolean(initialUser);
|
const initialIsAuthenticated = Boolean(initialUser);
|
||||||
|
|
||||||
|
// Initialize translations from server-rendered page data
|
||||||
|
setTranslations(
|
||||||
|
(initialPageProps?.translations as Record<string, string>) ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep translations in sync on every Inertia navigation
|
||||||
|
router.on('navigate', (event) => {
|
||||||
|
const pageProps = event.detail.page.props as SharedData;
|
||||||
|
setTranslations(
|
||||||
|
(pageProps?.translations as Record<string, string>) ?? {},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<EncryptionKeyProvider>
|
<EncryptionKeyProvider>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,10 @@ import {
|
||||||
convertSingleAccountData,
|
convertSingleAccountData,
|
||||||
useChartViews,
|
useChartViews,
|
||||||
} from '@/hooks/use-chart-views';
|
} from '@/hooks/use-chart-views';
|
||||||
|
import { useLocale } from '@/hooks/use-locale';
|
||||||
import { Account } from '@/types/account';
|
import { Account } from '@/types/account';
|
||||||
|
import { formatMonthFromYearMonth } from '@/utils/date';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { format, subMonths } from 'date-fns';
|
import { format, subMonths } from 'date-fns';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Bar, BarChart, XAxis } from 'recharts';
|
import { Bar, BarChart, XAxis } from 'recharts';
|
||||||
|
|
@ -52,21 +55,18 @@ interface AccountBalanceChartProps {
|
||||||
onBalanceClick?: () => void;
|
onBalanceClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatXAxisLabel(value: string): string {
|
function createXAxisFormatter(locale: string) {
|
||||||
const [year, month] = value.split('-');
|
return function formatXAxisLabel(value: string): string {
|
||||||
const date = new Date(parseInt(year), parseInt(month) - 1);
|
return formatMonthFromYearMonth(value, locale);
|
||||||
const monthName = date.toLocaleString('en-US', { month: 'short' });
|
};
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
if (parseInt(year) === currentYear) {
|
|
||||||
return monthName;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${monthName} ${year.slice(-2)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(value: number, currencyCode: string): string {
|
function formatChartCurrency(
|
||||||
return new Intl.NumberFormat('en-US', {
|
value: number,
|
||||||
|
currencyCode: string,
|
||||||
|
locale: string,
|
||||||
|
): string {
|
||||||
|
return new Intl.NumberFormat(locale, {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: currencyCode,
|
currency: currencyCode,
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
|
|
@ -104,6 +104,7 @@ export function AccountBalanceChart({
|
||||||
refreshKey,
|
refreshKey,
|
||||||
onBalanceClick,
|
onBalanceClick,
|
||||||
}: AccountBalanceChartProps) {
|
}: AccountBalanceChartProps) {
|
||||||
|
const locale = useLocale();
|
||||||
const [balanceData, setBalanceData] = useState<AccountBalanceData | null>(
|
const [balanceData, setBalanceData] = useState<AccountBalanceData | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
@ -181,12 +182,18 @@ export function AccountBalanceChart({
|
||||||
length={{ min: 5, max: 20 }}
|
length={{ min: 5, max: 20 }}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|
||||||
color: 'var(--color-chart-2)',
|
color: 'var(--color-chart-2)',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatXAxisLabel = useMemo(
|
||||||
|
() => createXAxisFormatter(locale),
|
||||||
|
[locale],
|
||||||
|
);
|
||||||
|
|
||||||
const valueFormatter = (value: number): string => {
|
const valueFormatter = (value: number): string => {
|
||||||
return formatCurrency(value, account.currency_code);
|
return formatChartCurrency(value, account.currency_code, locale);
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -204,7 +211,7 @@ export function AccountBalanceChart({
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Balance evolution</CardTitle>
|
<CardTitle>{__('Balance evolution')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
<div className="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
<div className="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|
@ -220,11 +227,11 @@ export function AccountBalanceChart({
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Balance evolution</CardTitle>
|
<CardTitle>{__('Balance evolution')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
||||||
No balance data available
|
{__('No balance data available')}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -236,7 +243,7 @@ export function AccountBalanceChart({
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-row items-start justify-between">
|
<div className="flex flex-row items-start justify-between">
|
||||||
<div className="flex flex-col gap-1 sm:gap-2">
|
<div className="flex flex-col gap-1 sm:gap-2">
|
||||||
<CardTitle>Balance evolution</CardTitle>
|
<CardTitle>{__('Balance evolution')}</CardTitle>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBalanceClick}
|
onClick={onBalanceClick}
|
||||||
|
|
@ -252,14 +259,15 @@ export function AccountBalanceChart({
|
||||||
<CardDescription className="flex flex-col gap-1 text-sm">
|
<CardDescription className="flex flex-col gap-1 text-sm">
|
||||||
<PercentageTrendIndicator
|
<PercentageTrendIndicator
|
||||||
trend={monthlyTrend?.percentage ?? null}
|
trend={monthlyTrend?.percentage ?? null}
|
||||||
label="this month"
|
label={__('this month')}
|
||||||
previousAmount={monthlyTrend?.previousValue}
|
previousAmount={monthlyTrend?.previousValue}
|
||||||
currentAmount={monthlyTrend?.currentValue}
|
currentAmount={monthlyTrend?.currentValue}
|
||||||
currencyCode={account.currency_code}
|
currencyCode={account.currency_code}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PercentageTrendIndicator
|
<PercentageTrendIndicator
|
||||||
trend={yearlyTrend?.percentage ?? null}
|
trend={yearlyTrend?.percentage ?? null}
|
||||||
label="for the last 12 months"
|
label={__('for the last 12 months')}
|
||||||
previousAmount={yearlyTrend?.previousValue}
|
previousAmount={yearlyTrend?.previousValue}
|
||||||
currentAmount={yearlyTrend?.currentValue}
|
currentAmount={yearlyTrend?.currentValue}
|
||||||
currencyCode={account.currency_code}
|
currencyCode={account.currency_code}
|
||||||
|
|
@ -295,6 +303,7 @@ export function AccountBalanceChart({
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={formatXAxisLabel}
|
tickFormatter={formatXAxisLabel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
|
|
@ -303,6 +312,7 @@ export function AccountBalanceChart({
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
fill="var(--color-chart-2)"
|
fill="var(--color-chart-2)"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
type Bank,
|
type Bank,
|
||||||
type CurrencyCode,
|
type CurrencyCode,
|
||||||
} from '@/types/account';
|
} from '@/types/account';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { BankCombobox } from './bank-combobox';
|
import { BankCombobox } from './bank-combobox';
|
||||||
import { CustomBankData, CustomBankForm } from './custom-bank-form';
|
import { CustomBankData, CustomBankForm } from './custom-bank-form';
|
||||||
|
|
@ -112,20 +113,20 @@ export function AccountForm({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="display_name">Name</Label>
|
<Label htmlFor="display_name">{__('Name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="display_name"
|
id="display_name"
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
name="display_name"
|
name="display_name"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
placeholder="Account name"
|
placeholder={__('Account name')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="bank_id">Bank</Label>
|
<Label htmlFor="bank_id">{__('Bank')}</Label>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{isCreatingCustomBank ? (
|
{isCreatingCustomBank ? (
|
||||||
<CustomBankForm
|
<CustomBankForm
|
||||||
|
|
@ -142,6 +143,7 @@ export function AccountForm({
|
||||||
value={selectedBankId ?? ''}
|
value={selectedBankId ?? ''}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BankCombobox
|
<BankCombobox
|
||||||
value={selectedBankId}
|
value={selectedBankId}
|
||||||
onValueChange={setSelectedBankId}
|
onValueChange={setSelectedBankId}
|
||||||
|
|
@ -154,7 +156,7 @@ export function AccountForm({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type">Account Type</Label>
|
<Label htmlFor="type">{__('Account Type')}</Label>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<Select
|
<Select
|
||||||
name="type"
|
name="type"
|
||||||
|
|
@ -166,7 +168,9 @@ export function AccountForm({
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<SelectTrigger name="type">
|
<SelectTrigger name="type">
|
||||||
<SelectValue placeholder="Select account type" />
|
<SelectValue
|
||||||
|
placeholder={__('Select account type')}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{ACCOUNT_TYPES.map((type) => (
|
{ACCOUNT_TYPES.map((type) => (
|
||||||
|
|
@ -180,14 +184,15 @@ export function AccountForm({
|
||||||
{(selectedType === 'investment' ||
|
{(selectedType === 'investment' ||
|
||||||
selectedType === 'retirement') && (
|
selectedType === 'retirement') && (
|
||||||
<p className="pl-1 text-xs text-muted-foreground">
|
<p className="pl-1 text-xs text-muted-foreground">
|
||||||
This account type is for balance tracking only and
|
{__(
|
||||||
doesn't support transactions.
|
"This account type is for balance tracking only and\n doesn't support transactions.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="currency_code">Currency</Label>
|
<Label htmlFor="currency_code">{__('Currency')}</Label>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<Select
|
<Select
|
||||||
name="currency_code"
|
name="currency_code"
|
||||||
|
|
@ -198,7 +203,7 @@ export function AccountForm({
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<SelectTrigger name="currency_code">
|
<SelectTrigger name="currency_code">
|
||||||
<SelectValue placeholder="Select currency" />
|
<SelectValue placeholder={__('Select currency')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{CURRENCY_OPTIONS.map((currency) => (
|
{CURRENCY_OPTIONS.map((currency) => (
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { EncryptedText } from '@/components/encrypted-text';
|
||||||
import { AmountDisplay } from '@/components/ui/amount-display';
|
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
|
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts';
|
import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||||
|
|
@ -108,7 +109,7 @@ export function AccountListCard({
|
||||||
<AmountTrendIndicator
|
<AmountTrendIndicator
|
||||||
isPositive={isPositive}
|
isPositive={isPositive}
|
||||||
trend={Math.abs(account.diff)}
|
trend={Math.abs(account.diff)}
|
||||||
label="vs last month"
|
label={__('vs last month')}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
previousAmount={account.previousBalance}
|
previousAmount={account.previousBalance}
|
||||||
currentAmount={account.currentBalance}
|
currentAmount={account.currentBalance}
|
||||||
|
|
@ -147,6 +148,7 @@ export function AccountListCard({
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
|
|
@ -163,12 +165,12 @@ export function AccountListCard({
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => setUpdateBalanceOpen(true)}
|
onClick={() => setUpdateBalanceOpen(true)}
|
||||||
>
|
>
|
||||||
Update balance
|
{__('Update balance')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Link href={show.url(account.id)}>
|
<Link href={show.url(account.id)}>
|
||||||
<Button className="cursor-pointer" variant="ghost">
|
<Button className="cursor-pointer" variant="ghost">
|
||||||
Details →
|
{__('Details')} →
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import type { Account, AccountBalance } from '@/types/account';
|
import type { Account, AccountBalance } from '@/types/account';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2 } from 'lucide-react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -218,9 +219,11 @@ export function BalancesModal({
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[600px]">
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Balance History</DialogTitle>
|
<DialogTitle>{__('Balance History')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
View and manage balance records for this account.
|
{__(
|
||||||
|
'View and manage balance records for this account.',
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -235,12 +238,12 @@ export function BalancesModal({
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Date</TableHead>
|
<TableHead>{__('Date')}</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right">
|
||||||
Balance
|
{__('Balance')}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[100px] text-right">
|
<TableHead className="w-[100px] text-right">
|
||||||
Actions
|
{__('Actions')}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -251,7 +254,7 @@ export function BalancesModal({
|
||||||
colSpan={3}
|
colSpan={3}
|
||||||
className="h-24 text-center"
|
className="h-24 text-center"
|
||||||
>
|
>
|
||||||
Loading...
|
{__('Loading...')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : balances.length === 0 ? (
|
) : balances.length === 0 ? (
|
||||||
|
|
@ -260,7 +263,9 @@ export function BalancesModal({
|
||||||
colSpan={3}
|
colSpan={3}
|
||||||
className="h-24 text-center"
|
className="h-24 text-center"
|
||||||
>
|
>
|
||||||
No balance records found.
|
{__(
|
||||||
|
'No balance records found.',
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -313,7 +318,10 @@ export function BalancesModal({
|
||||||
{lastPage > 1 && (
|
{lastPage > 1 && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{total} balance record{total !== 1 ? 's' : ''}
|
{total}{' '}
|
||||||
|
{total === 1
|
||||||
|
? __('balance record')
|
||||||
|
: __('balance records')}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -324,10 +332,11 @@ export function BalancesModal({
|
||||||
}
|
}
|
||||||
disabled={currentPage <= 1 || isLoading}
|
disabled={currentPage <= 1 || isLoading}
|
||||||
>
|
>
|
||||||
Previous
|
{__('Previous')}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
Page {currentPage} of {lastPage}
|
{__('Page')} {currentPage} {__('of')}{' '}
|
||||||
|
{lastPage}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -339,7 +348,7 @@ export function BalancesModal({
|
||||||
currentPage >= lastPage || isLoading
|
currentPage >= lastPage || isLoading
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Next
|
{__('Next')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -353,15 +362,15 @@ export function BalancesModal({
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-[400px]">
|
<DialogContent className="sm:max-w-[400px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Balance</DialogTitle>
|
<DialogTitle>{__('Edit Balance')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update the balance record.
|
{__('Update the balance record.')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleEditSubmit} className="space-y-4">
|
<form onSubmit={handleEditSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="edit-amount">Balance</Label>
|
<Label htmlFor="edit-amount">{__('Balance')}</Label>
|
||||||
<AmountInput
|
<AmountInput
|
||||||
id="edit-amount"
|
id="edit-amount"
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
|
|
@ -373,7 +382,7 @@ export function BalancesModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="edit-date">Date</Label>
|
<Label htmlFor="edit-date">{__('Date')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-date"
|
id="edit-date"
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
|
|
@ -391,10 +400,12 @@ export function BalancesModal({
|
||||||
onClick={() => setEditingBalance(null)}
|
onClick={() => setEditingBalance(null)}
|
||||||
disabled={isEditSubmitting}
|
disabled={isEditSubmitting}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isEditSubmitting}>
|
<Button type="submit" disabled={isEditSubmitting}>
|
||||||
{isEditSubmitting ? 'Saving...' : 'Save'}
|
{isEditSubmitting
|
||||||
|
? __('Saving...')
|
||||||
|
: __('Save')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -407,22 +418,25 @@ export function BalancesModal({
|
||||||
>
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete balance</AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
|
{__('Delete balance')}
|
||||||
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete this balance record?
|
{__(
|
||||||
This action cannot be undone.
|
'Are you sure you want to delete this balance record? This action cannot be undone.',
|
||||||
|
)}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={isDeleting}>
|
<AlertDialogCancel disabled={isDeleting}>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
variant={'destructive'}
|
variant={'destructive'}
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
>
|
>
|
||||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
{isDeleting ? __('Deleting...') : __('Delete')}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
} from '@/components/ui/popover';
|
} from '@/components/ui/popover';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { type Bank } from '@/types/account';
|
import { type Bank } from '@/types/account';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Check, ChevronsUpDown, Plus } from 'lucide-react';
|
import { Check, ChevronsUpDown, Plus } from 'lucide-react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -121,7 +122,7 @@ export function BankCombobox({
|
||||||
<span>{selectedBank.name}</span>
|
<span>{selectedBank.name}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
'Select bank...'
|
__('Select bank...')
|
||||||
)}
|
)}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -132,17 +133,18 @@ export function BankCombobox({
|
||||||
>
|
>
|
||||||
<Command filter={() => 1}>
|
<Command filter={() => 1}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search bank..."
|
placeholder={__('Search bank...')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onValueChange={setSearchQuery}
|
onValueChange={setSearchQuery}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
{isLoading
|
{isLoading
|
||||||
? 'Searching...'
|
? __('Searching...')
|
||||||
: searchQuery.length < 3
|
: searchQuery.length < 3
|
||||||
? 'Type at least 3 characters to search'
|
? __('Type at least 3 characters to search')
|
||||||
: 'No bank found.'}
|
: __('No bank found.')}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{banks.map((bank) => (
|
{banks.map((bank) => (
|
||||||
|
|
@ -190,7 +192,8 @@ export function BankCombobox({
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
<span>
|
<span>
|
||||||
Create "{searchQuery}"
|
{__('Create "')}
|
||||||
|
{searchQuery}"
|
||||||
</span>
|
</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import { encrypt, importKey } from '@/lib/crypto';
|
import { encrypt, importKey } from '@/lib/crypto';
|
||||||
import { getStoredKey } from '@/lib/key-storage';
|
import { getStoredKey } from '@/lib/key-storage';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { AccountForm, AccountFormData } from './account-form';
|
import { AccountForm, AccountFormData } from './account-form';
|
||||||
|
|
@ -163,7 +164,7 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const createButton = (
|
const createButton = (
|
||||||
<Button disabled={!isKeyAvailable}>Create Account</Button>
|
<Button disabled={!isKeyAvailable}>{__('Create Account')}</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -189,9 +190,11 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) {
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create Account</DialogTitle>
|
<DialogTitle>{__('Create Account')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Add a new bank account to track your transactions.
|
{__(
|
||||||
|
'Add a new bank account to track your transactions.',
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-2">
|
<form onSubmit={handleSubmit} className="space-y-2">
|
||||||
|
|
@ -204,7 +207,7 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) {
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { ImagePlus, X } from 'lucide-react';
|
import { ImagePlus, X } from 'lucide-react';
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -112,10 +113,10 @@ export function CustomBankForm({
|
||||||
|
|
||||||
<div className="mt-1 space-y-2 rounded-sm border border-border p-3">
|
<div className="mt-1 space-y-2 rounded-sm border border-border p-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="bank_name">Bank name</Label>
|
<Label htmlFor="bank_name">{__('Bank name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="bank_name"
|
id="bank_name"
|
||||||
placeholder="Bank name"
|
placeholder={__('Bank name')}
|
||||||
defaultValue={defaultName}
|
defaultValue={defaultName}
|
||||||
value={value.name}
|
value={value.name}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
|
|
@ -125,7 +126,7 @@ export function CustomBankForm({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="bank_logo">Logo</Label>
|
<Label htmlFor="bank_logo">{__('Logo')}</Label>
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
|
@ -134,6 +135,7 @@ export function CustomBankForm({
|
||||||
accept="image/png,image/jpeg,image/webp"
|
accept="image/png,image/jpeg,image/webp"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
|
@ -142,7 +144,7 @@ export function CustomBankForm({
|
||||||
{value.logoPreview ? (
|
{value.logoPreview ? (
|
||||||
<img
|
<img
|
||||||
src={value.logoPreview}
|
src={value.logoPreview}
|
||||||
alt="Bank logo preview"
|
alt={__('Bank logo preview')}
|
||||||
className="size-5 rounded-full object-contain"
|
className="size-5 rounded-full object-contain"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -152,8 +154,11 @@ export function CustomBankForm({
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
Square images only (max {MAX_DIMENSIONS}x
|
{__('Square images only (max')}
|
||||||
{MAX_DIMENSIONS}px) / {MAX_FILE_SIZE / 1024}KB max.
|
{MAX_DIMENSIONS}x{MAX_DIMENSIONS}
|
||||||
|
{__('px) /')}
|
||||||
|
{MAX_FILE_SIZE / 1024}
|
||||||
|
{__('KB max.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { type Account } from '@/types/account';
|
import { type Account } from '@/types/account';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Form, router } from '@inertiajs/react';
|
import { Form, router } from '@inertiajs/react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -44,28 +45,30 @@ export function DeleteAccountDialog({
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Account</DialogTitle>
|
<DialogTitle>{__('Delete Account')}</DialogTitle>
|
||||||
<DialogDescription className="space-y-2">
|
<DialogDescription className="space-y-2">
|
||||||
<p>
|
<p>
|
||||||
This action is irreversible. All transactions in
|
{__(
|
||||||
this account will also be permanently deleted.
|
'This action is irreversible. All transactions in\n this account will also be permanently deleted.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-semibold">
|
<p className="font-semibold">
|
||||||
Type <span className="text-red-600">DELETE</span> to
|
{__('Type')}
|
||||||
confirm.
|
<span className="text-red-600">DELETE</span>
|
||||||
|
{__('to\n confirm.')}
|
||||||
</p>
|
</p>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirm">Confirmation</Label>
|
<Label htmlFor="confirm">{__('Confirmation')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="confirm"
|
id="confirm"
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
value={confirmText}
|
value={confirmText}
|
||||||
onChange={(e) => setConfirmText(e.target.value)}
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
placeholder="Type DELETE"
|
placeholder={__('Type DELETE')}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -89,7 +92,7 @@ export function DeleteAccountDialog({
|
||||||
onClick={() => handleOpenChange(false)}
|
onClick={() => handleOpenChange(false)}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
import { decrypt, encrypt, importKey } from '@/lib/crypto';
|
import { decrypt, encrypt, importKey } from '@/lib/crypto';
|
||||||
import { getStoredKey } from '@/lib/key-storage';
|
import { getStoredKey } from '@/lib/key-storage';
|
||||||
import type { Account } from '@/types/account';
|
import type { Account } from '@/types/account';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { AccountForm, AccountFormData } from './account-form';
|
import { AccountForm, AccountFormData } from './account-form';
|
||||||
|
|
@ -202,9 +203,9 @@ export function EditAccountDialog({
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Account</DialogTitle>
|
<DialogTitle>{__('Edit Account')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update the account information.
|
{__('Update the account information.')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-2">
|
<form onSubmit={handleSubmit} className="space-y-2">
|
||||||
|
|
@ -229,7 +230,7 @@ export function EditAccountDialog({
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
} from '@/types/balance-import';
|
} from '@/types/balance-import';
|
||||||
import { DateFormat } from '@/types/import';
|
import { DateFormat } from '@/types/import';
|
||||||
import type { UUID } from '@/types/uuid';
|
import type { UUID } from '@/types/uuid';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Check } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
@ -155,6 +156,7 @@ export function ImportBalancesDrawer({
|
||||||
'balance_date',
|
'balance_date',
|
||||||
'f. valor',
|
'f. valor',
|
||||||
];
|
];
|
||||||
|
|
||||||
const balancePatterns = [
|
const balancePatterns = [
|
||||||
'balance',
|
'balance',
|
||||||
'saldo',
|
'saldo',
|
||||||
|
|
@ -554,6 +556,7 @@ export function ImportBalancesDrawer({
|
||||||
onNext={() => moveToStep(BalanceImportStep.UploadFile)}
|
onNext={() => moveToStep(BalanceImportStep.UploadFile)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case BalanceImportStep.UploadFile:
|
case BalanceImportStep.UploadFile:
|
||||||
return (
|
return (
|
||||||
<ImportBalanceStepUpload
|
<ImportBalanceStepUpload
|
||||||
|
|
@ -563,6 +566,7 @@ export function ImportBalancesDrawer({
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case BalanceImportStep.MapColumns:
|
case BalanceImportStep.MapColumns:
|
||||||
return (
|
return (
|
||||||
<ImportBalanceStepMapping
|
<ImportBalanceStepMapping
|
||||||
|
|
@ -578,6 +582,7 @@ export function ImportBalancesDrawer({
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case BalanceImportStep.Preview:
|
case BalanceImportStep.Preview:
|
||||||
return (
|
return (
|
||||||
<ImportBalanceStepPreview
|
<ImportBalanceStepPreview
|
||||||
|
|
@ -588,6 +593,7 @@ export function ImportBalancesDrawer({
|
||||||
isImporting={isImporting}
|
isImporting={isImporting}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -602,7 +608,8 @@ export function ImportBalancesDrawer({
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{importProgress} of {importTotal} balances imported
|
{importProgress} of {importTotal}
|
||||||
|
{__('balances imported')}
|
||||||
</span>
|
</span>
|
||||||
<span>{Math.round(percentage)}%</span>
|
<span>{Math.round(percentage)}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -613,7 +620,8 @@ export function ImportBalancesDrawer({
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-destructive">
|
<h3 className="text-sm font-medium text-destructive">
|
||||||
Errors ({importErrors.length})
|
{__('Errors (')}
|
||||||
|
{importErrors.length})
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[300px] overflow-y-auto rounded-lg border">
|
<div className="max-h-[300px] overflow-y-auto rounded-lg border">
|
||||||
|
|
@ -621,16 +629,16 @@ export function ImportBalancesDrawer({
|
||||||
<thead className="sticky top-0 bg-muted">
|
<thead className="sticky top-0 bg-muted">
|
||||||
<tr className="border-b">
|
<tr className="border-b">
|
||||||
<th className="px-4 py-2 text-left font-medium">
|
<th className="px-4 py-2 text-left font-medium">
|
||||||
Row
|
{__('Row')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-2 text-left font-medium">
|
<th className="px-4 py-2 text-left font-medium">
|
||||||
Date
|
{__('Date')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-2 text-left font-medium">
|
<th className="px-4 py-2 text-left font-medium">
|
||||||
Balance
|
{__('Balance')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-2 text-left font-medium">
|
<th className="px-4 py-2 text-left font-medium">
|
||||||
Error
|
{__('Error')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Label } from '@/components/ui/label';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { type Account } from '@/types/account';
|
import { type Account } from '@/types/account';
|
||||||
import type { UUID } from '@/types/uuid';
|
import type { UUID } from '@/types/uuid';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Building2 } from 'lucide-react';
|
import { Building2 } from 'lucide-react';
|
||||||
|
|
||||||
interface ImportBalanceStepAccountProps {
|
interface ImportBalanceStepAccountProps {
|
||||||
|
|
@ -23,7 +24,7 @@ export function ImportBalanceStepAccount({
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No accounts found. Please create an account first.
|
{__('No accounts found. Please create an account first.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -46,6 +47,7 @@ export function ImportBalanceStepAccount({
|
||||||
value={account.id}
|
value={account.id}
|
||||||
id={`account-${account.id}`}
|
id={`account-${account.id}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{account.bank.logo ? (
|
{account.bank.logo ? (
|
||||||
<img
|
<img
|
||||||
src={account.bank.logo}
|
src={account.bank.logo}
|
||||||
|
|
@ -77,7 +79,7 @@ export function ImportBalanceStepAccount({
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={onNext} disabled={!selectedAccountId}>
|
<Button onClick={onNext} disabled={!selectedAccountId}>
|
||||||
Next
|
{__('Next')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import type {
|
||||||
ParsedRow,
|
ParsedRow,
|
||||||
} from '@/types/balance-import';
|
} from '@/types/balance-import';
|
||||||
import { DateFormat } from '@/types/import';
|
import { DateFormat } from '@/types/import';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
|
|
||||||
interface ImportBalanceStepMappingProps {
|
interface ImportBalanceStepMappingProps {
|
||||||
columnOptions: ColumnOption[];
|
columnOptions: ColumnOption[];
|
||||||
|
|
@ -77,7 +78,8 @@ export function ImportBalanceStepMapping({
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="date-column">
|
<Label htmlFor="date-column">
|
||||||
Balance Date <span className="text-destructive">*</span>
|
{__('Balance Date')}
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={columnMapping.balance_date || ''}
|
value={columnMapping.balance_date || ''}
|
||||||
|
|
@ -86,7 +88,9 @@ export function ImportBalanceStepMapping({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="date-column">
|
<SelectTrigger id="date-column">
|
||||||
<SelectValue placeholder="Select date column" />
|
<SelectValue
|
||||||
|
placeholder={__('Select date column')}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{columnOptions.map((option, index) => (
|
{columnOptions.map((option, index) => (
|
||||||
|
|
@ -105,7 +109,8 @@ export function ImportBalanceStepMapping({
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="balance-column">
|
<Label htmlFor="balance-column">
|
||||||
Balance <span className="text-destructive">*</span>
|
{__('Balance')}
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={columnMapping.balance || ''}
|
value={columnMapping.balance || ''}
|
||||||
|
|
@ -114,7 +119,9 @@ export function ImportBalanceStepMapping({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="balance-column">
|
<SelectTrigger id="balance-column">
|
||||||
<SelectValue placeholder="Select balance column" />
|
<SelectValue
|
||||||
|
placeholder={__('Select balance column')}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{columnOptions.map((option, index) => (
|
{columnOptions.map((option, index) => (
|
||||||
|
|
@ -132,7 +139,7 @@ export function ImportBalanceStepMapping({
|
||||||
{isValid && previewBalances.length > 0 && (
|
{isValid && previewBalances.length > 0 && (
|
||||||
<div className="space-y-4 rounded-lg border bg-muted/30 p-4">
|
<div className="space-y-4 rounded-lg border bg-muted/30 p-4">
|
||||||
<Label className="pl-2 text-xs font-light tracking-widest uppercase opacity-50">
|
<Label className="pl-2 text-xs font-light tracking-widest uppercase opacity-50">
|
||||||
Preview (first 3 rows)
|
{__('Preview (first 3 rows)')}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
{previewBalances.map((balance, index) => (
|
{previewBalances.map((balance, index) => (
|
||||||
|
|
@ -154,7 +161,7 @@ export function ImportBalanceStepMapping({
|
||||||
|
|
||||||
{!dateFormatDetected && (
|
{!dateFormatDetected && (
|
||||||
<div className="space-y-3 rounded-lg border p-4">
|
<div className="space-y-3 rounded-lg border p-4">
|
||||||
<Label>Date Format</Label>
|
<Label>{__('Date Format')}</Label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={dateFormat}
|
value={dateFormat}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|
@ -166,11 +173,12 @@ export function ImportBalanceStepMapping({
|
||||||
value={DateFormat.YearMonthDay}
|
value={DateFormat.YearMonthDay}
|
||||||
id="format-ymd"
|
id="format-ymd"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Label
|
<Label
|
||||||
htmlFor="format-ymd"
|
htmlFor="format-ymd"
|
||||||
className="cursor-pointer font-normal"
|
className="cursor-pointer font-normal"
|
||||||
>
|
>
|
||||||
YYYY-MM-DD (e.g., 2024-12-31)
|
{__('YYYY-MM-DD (e.g., 2024-12-31)')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
|
@ -178,11 +186,12 @@ export function ImportBalanceStepMapping({
|
||||||
value={DateFormat.MonthDayYear}
|
value={DateFormat.MonthDayYear}
|
||||||
id="format-mdy"
|
id="format-mdy"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Label
|
<Label
|
||||||
htmlFor="format-mdy"
|
htmlFor="format-mdy"
|
||||||
className="cursor-pointer font-normal"
|
className="cursor-pointer font-normal"
|
||||||
>
|
>
|
||||||
MM-DD-YYYY (e.g., 12-31-2024)
|
{__('MM-DD-YYYY (e.g., 12-31-2024)')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
|
@ -190,11 +199,12 @@ export function ImportBalanceStepMapping({
|
||||||
value={DateFormat.DayMonthYear}
|
value={DateFormat.DayMonthYear}
|
||||||
id="format-dmy"
|
id="format-dmy"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Label
|
<Label
|
||||||
htmlFor="format-dmy"
|
htmlFor="format-dmy"
|
||||||
className="cursor-pointer font-normal"
|
className="cursor-pointer font-normal"
|
||||||
>
|
>
|
||||||
DD-MM-YYYY (e.g., 31-12-2024)
|
{__('DD-MM-YYYY (e.g., 31-12-2024)')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
|
|
@ -204,10 +214,10 @@ export function ImportBalanceStepMapping({
|
||||||
|
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<Button variant="outline" onClick={onBack}>
|
<Button variant="outline" onClick={onBack}>
|
||||||
Back
|
{__('Back')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onNext} disabled={!isValid}>
|
<Button onClick={onNext} disabled={!isValid}>
|
||||||
Preview Balances
|
{__('Preview Balances')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
import { useLocale } from '@/hooks/use-locale';
|
||||||
import { type ParsedBalance } from '@/types/balance-import';
|
import { type ParsedBalance } from '@/types/balance-import';
|
||||||
|
import { formatDateMedium } from '@/utils/date';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
|
|
||||||
interface ImportBalanceStepPreviewProps {
|
interface ImportBalanceStepPreviewProps {
|
||||||
balances: ParsedBalance[];
|
balances: ParsedBalance[];
|
||||||
|
|
@ -24,30 +27,22 @@ export function ImportBalanceStepPreview({
|
||||||
onBack,
|
onBack,
|
||||||
isImporting,
|
isImporting,
|
||||||
}: ImportBalanceStepPreviewProps) {
|
}: ImportBalanceStepPreviewProps) {
|
||||||
|
const locale = useLocale();
|
||||||
const total = balances.length;
|
const total = balances.length;
|
||||||
|
|
||||||
const formatBalance = (balance: number): string => {
|
const formatBalance = (balance: number): string => {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat(locale, {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: currencyCode,
|
currency: currencyCode,
|
||||||
}).format(balance / 100);
|
}).format(balance / 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string): string => {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="rounded-lg border bg-muted/50 p-4">
|
<div className="rounded-lg border bg-muted/50 p-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{total} balance{total !== 1 ? 's' : ''} will be updated or
|
{total} balance{total !== 1 ? 's' : ''}
|
||||||
created.
|
{__('will be updated or\n created.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -55,9 +50,9 @@ export function ImportBalanceStepPreview({
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Date</TableHead>
|
<TableHead>{__('Date')}</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right">
|
||||||
Balance
|
{__('Balance')}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -75,7 +70,10 @@ export function ImportBalanceStepPreview({
|
||||||
balances.map((balance, index) => (
|
balances.map((balance, index) => (
|
||||||
<TableRow key={index}>
|
<TableRow key={index}>
|
||||||
<TableCell className="whitespace-nowrap">
|
<TableCell className="whitespace-nowrap">
|
||||||
{formatDate(balance.balance_date)}
|
{formatDateMedium(
|
||||||
|
balance.balance_date,
|
||||||
|
locale,
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-mono">
|
<TableCell className="text-right font-mono">
|
||||||
{formatBalance(balance.balance)}
|
{formatBalance(balance.balance)}
|
||||||
|
|
@ -93,7 +91,7 @@ export function ImportBalanceStepPreview({
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
disabled={isImporting}
|
disabled={isImporting}
|
||||||
>
|
>
|
||||||
Back
|
{__('Back')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { FileSpreadsheet, Upload, X } from 'lucide-react';
|
import { FileSpreadsheet, Upload, X } from 'lucide-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -89,14 +90,15 @@ export function ImportBalanceStepUpload({
|
||||||
<>
|
<>
|
||||||
<Upload className="mb-4 h-12 w-12 text-muted-foreground" />
|
<Upload className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||||
<p className="mb-2 text-sm font-medium">
|
<p className="mb-2 text-sm font-medium">
|
||||||
Drop your file here, or click to browse
|
{__('Drop your file here, or click to browse')}
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-4 text-xs text-muted-foreground">
|
<p className="mb-4 text-xs text-muted-foreground">
|
||||||
Supports CSV, XLS, and XLSX files
|
{__('Supports CSV, XLS, and XLSX files')}
|
||||||
</p>
|
</p>
|
||||||
<Button asChild variant="secondary">
|
<Button asChild variant="secondary">
|
||||||
<label className="cursor-pointer">
|
<label className="cursor-pointer">
|
||||||
Browse Files
|
{__('Browse Files')}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
|
|
@ -135,10 +137,10 @@ export function ImportBalanceStepUpload({
|
||||||
|
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<Button variant="outline" onClick={onBack}>
|
<Button variant="outline" onClick={onBack}>
|
||||||
Back
|
{__('Back')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onNext} disabled={!file}>
|
<Button onClick={onNext} disabled={!file}>
|
||||||
Next
|
{__('Next')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import type { Account, AccountBalance } from '@/types/account';
|
import type { Account, AccountBalance } from '@/types/account';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
interface UpdateBalanceDialogProps {
|
interface UpdateBalanceDialogProps {
|
||||||
|
|
@ -149,18 +150,20 @@ export function UpdateBalanceDialog({
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent hasKeyboard className="sm:max-w-[400px]">
|
<DialogContent hasKeyboard className="sm:max-w-[400px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Update balance</DialogTitle>
|
<DialogTitle>{__('Update balance')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Set the balance for this account on a specific date.
|
{__(
|
||||||
|
'Set the balance for this account on a specific date.',
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="balance-amount">Balance</Label>
|
<Label htmlFor="balance-amount">{__('Balance')}</Label>
|
||||||
{isLoadingLastBalance ? (
|
{isLoadingLastBalance ? (
|
||||||
<div className="flex h-10 items-center rounded-md border border-input bg-muted px-3 text-sm text-muted-foreground">
|
<div className="flex h-10 items-center rounded-md border border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||||
Loading last balance...
|
{__('Loading last balance...')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<AmountInput
|
<AmountInput
|
||||||
|
|
@ -176,7 +179,7 @@ export function UpdateBalanceDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="balance-date">Date</Label>
|
<Label htmlFor="balance-date">{__('Date')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="balance-date"
|
id="balance-date"
|
||||||
type="date"
|
type="date"
|
||||||
|
|
@ -198,17 +201,17 @@ export function UpdateBalanceDialog({
|
||||||
onClick={() => handleOpenChange(false)}
|
onClick={() => handleOpenChange(false)}
|
||||||
disabled={isSubmitting || isLoadingLastBalance}
|
disabled={isSubmitting || isLoadingLastBalance}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || isLoadingLastBalance}
|
disabled={isSubmitting || isLoadingLastBalance}
|
||||||
>
|
>
|
||||||
{isSubmitting
|
{isSubmitting
|
||||||
? 'Saving...'
|
? __('Saving...')
|
||||||
: isLoadingLastBalance
|
: isLoadingLastBalance
|
||||||
? 'Loading...'
|
? __('Loading...')
|
||||||
: 'Save'}
|
: __('Save')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useAppearance } from '@/hooks/use-appearance';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
|
@ -36,20 +37,20 @@ export default function AppearanceToggleDropdown({
|
||||||
className="h-9 w-9 rounded-md"
|
className="h-9 w-9 rounded-md"
|
||||||
>
|
>
|
||||||
{getCurrentIcon()}
|
{getCurrentIcon()}
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">{__('Toggle theme')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => updateAppearance('light')}>
|
<DropdownMenuItem onClick={() => updateAppearance('light')}>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Sun className="h-5 w-5" />
|
<Sun className="h-5 w-5" />
|
||||||
Light
|
{__('Light')}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => updateAppearance('dark')}>
|
<DropdownMenuItem onClick={() => updateAppearance('dark')}>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Moon className="h-5 w-5" />
|
<Moon className="h-5 w-5" />
|
||||||
Dark
|
{__('Dark')}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|
@ -57,7 +58,7 @@ export default function AppearanceToggleDropdown({
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Monitor className="h-5 w-5" />
|
<Monitor className="h-5 w-5" />
|
||||||
System
|
{__('System')}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Appearance, useAppearance } from '@/hooks/use-appearance';
|
import { Appearance, useAppearance } from '@/hooks/use-appearance';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { LucideIcon, Monitor, Moon, Sun } from 'lucide-react';
|
import { LucideIcon, Monitor, Moon, Sun } from 'lucide-react';
|
||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
|
@ -10,9 +11,9 @@ export default function AppearanceToggleTab({
|
||||||
const { appearance, updateAppearance } = useAppearance();
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
|
|
||||||
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
|
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
|
||||||
{ value: 'light', icon: Sun, label: 'Light' },
|
{ value: 'light', icon: Sun, label: __('Light') },
|
||||||
{ value: 'dark', icon: Moon, label: 'Dark' },
|
{ value: 'dark', icon: Moon, label: __('Dark') },
|
||||||
{ value: 'system', icon: Monitor, label: 'System' },
|
{ value: 'system', icon: Monitor, label: __('System') },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||||
import { type AutomationRule, getRuleActions } from '@/types/automation-rule';
|
import { type AutomationRule, getRuleActions } from '@/types/automation-rule';
|
||||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||||
import { type Label } from '@/types/label';
|
import { type Label } from '@/types/label';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
|
|
||||||
interface AutomationRulesDialogProps {
|
interface AutomationRulesDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -81,22 +82,22 @@ function AutomationRuleActions({
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
aria-label="Actions"
|
aria-label={__('Actions')}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Open menu</span>
|
<span className="sr-only">{__('Open menu')}</span>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuLabel>{__('Actions')}</DropdownMenuLabel>
|
||||||
<DropdownMenuItem onClick={() => setEditOpen(true)}>
|
<DropdownMenuItem onClick={() => setEditOpen(true)}>
|
||||||
Edit
|
{__('Edit')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => setDeleteOpen(true)}
|
onClick={() => setDeleteOpen(true)}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
>
|
>
|
||||||
Delete
|
{__('Delete')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
@ -154,15 +155,15 @@ function AutomationRuleRow({
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuLabel>Actions</ContextMenuLabel>
|
<ContextMenuLabel>{__('Actions')}</ContextMenuLabel>
|
||||||
<ContextMenuItem onClick={() => setEditOpen(true)}>
|
<ContextMenuItem onClick={() => setEditOpen(true)}>
|
||||||
Edit
|
{__('Edit')}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => setDeleteOpen(true)}
|
onClick={() => setDeleteOpen(true)}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
>
|
>
|
||||||
Delete
|
{__('Delete')}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|
@ -220,7 +221,7 @@ export function AutomationRulesDialog({
|
||||||
const columns: ColumnDef<AutomationRule>[] = [
|
const columns: ColumnDef<AutomationRule>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'priority',
|
accessorKey: 'priority',
|
||||||
header: 'Priority',
|
header: __('Priority'),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
|
|
@ -231,7 +232,7 @@ export function AutomationRulesDialog({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'title',
|
accessorKey: 'title',
|
||||||
header: 'Title',
|
header: __('Title'),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<div className="font-medium">{row.getValue('title')}</div>
|
<div className="font-medium">{row.getValue('title')}</div>
|
||||||
|
|
@ -240,7 +241,7 @@ export function AutomationRulesDialog({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions_display',
|
id: 'actions_display',
|
||||||
header: 'Actions',
|
header: __('Actions'),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const rule = row.original;
|
const rule = row.original;
|
||||||
const actions = getRuleActions(rule);
|
const actions = getRuleActions(rule);
|
||||||
|
|
@ -299,7 +300,7 @@ export function AutomationRulesDialog({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Add note
|
{__('Add note')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -337,17 +338,18 @@ export function AutomationRulesDialog({
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-3xl">
|
<DialogContent className="sm:max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Automation Rules</DialogTitle>
|
<DialogTitle>{__('Automation Rules')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Manage your transaction automation rules. Rules will be
|
{__(
|
||||||
applied to categorize transactions automatically.
|
'Manage your transaction automation rules. Rules will be applied to categorize transactions automatically.',
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Filter rules...."
|
placeholder={__('Filter rules...')}
|
||||||
value={
|
value={
|
||||||
(table
|
(table
|
||||||
.getColumn('title')
|
.getColumn('title')
|
||||||
|
|
@ -407,7 +409,7 @@ export function AutomationRulesDialog({
|
||||||
colSpan={columns.length}
|
colSpan={columns.length}
|
||||||
className="h-24 text-center"
|
className="h-24 text-center"
|
||||||
>
|
>
|
||||||
No automation rules found.
|
{__('No automation rules found.')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|
@ -417,8 +419,8 @@ export function AutomationRulesDialog({
|
||||||
|
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{table.getFilteredRowModel().rows.length} rule(s)
|
{table.getFilteredRowModel().rows.length}{' '}
|
||||||
total.
|
{__('rule(s) total.')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
} from '@/lib/rule-builder-utils';
|
} from '@/lib/rule-builder-utils';
|
||||||
import type { Category } from '@/types/category';
|
import type { Category } from '@/types/category';
|
||||||
import type { Label } from '@/types/label';
|
import type { Label } from '@/types/label';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -124,26 +125,28 @@ export function CreateAutomationRuleDialog({
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button disabled={disabled}>Create Rule</Button>
|
<Button disabled={disabled}>{__('Create Rule')}</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="overflow-x-hidden sm:max-w-[600px]">
|
<DialogContent className="overflow-x-hidden sm:max-w-[600px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create Automation Rule</DialogTitle>
|
<DialogTitle>{__('Create Automation Rule')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Create a rule to automatically categorize transactions
|
{__(
|
||||||
and add labels.
|
'Create a rule to automatically categorize transactions\n and add labels.',
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FormLabel htmlFor="title">Title</FormLabel>
|
<FormLabel htmlFor="title">{__('Title')}</FormLabel>
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Rule title"
|
placeholder={__('Rule title')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{errors.title && (
|
{errors.title && (
|
||||||
<p className="text-sm text-red-500">
|
<p className="text-sm text-red-500">
|
||||||
{errors.title}
|
{errors.title}
|
||||||
|
|
@ -152,7 +155,9 @@ export function CreateAutomationRuleDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FormLabel htmlFor="priority">Priority</FormLabel>
|
<FormLabel htmlFor="priority">
|
||||||
|
{__('Priority')}
|
||||||
|
</FormLabel>
|
||||||
<Input
|
<Input
|
||||||
id="priority"
|
id="priority"
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -162,8 +167,9 @@ export function CreateAutomationRuleDialog({
|
||||||
placeholder="10"
|
placeholder="10"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Lower numbers execute first
|
{__('Lower numbers execute first')}
|
||||||
</p>
|
</p>
|
||||||
{errors.priority && (
|
{errors.priority && (
|
||||||
<p className="text-sm text-red-500">
|
<p className="text-sm text-red-500">
|
||||||
|
|
@ -179,21 +185,23 @@ export function CreateAutomationRuleDialog({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-4 rounded-md border p-4">
|
<div className="space-y-4 rounded-md border p-4">
|
||||||
<h4 className="font-medium">Actions</h4>
|
<h4 className="font-medium">{__('Actions')}</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
At least one action is required
|
{__('At least one action is required')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FormLabel htmlFor="category">
|
<FormLabel htmlFor="category">
|
||||||
Set Category
|
{__('Set Category')}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<CategoryCombobox
|
<CategoryCombobox
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onValueChange={setCategoryId}
|
onValueChange={setCategoryId}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
placeholder="Select a category (optional)"
|
placeholder={__(
|
||||||
|
'Select a category (optional)',
|
||||||
|
)}
|
||||||
showUncategorized={false}
|
showUncategorized={false}
|
||||||
data-testid="action-category-select"
|
data-testid="action-category-select"
|
||||||
/>
|
/>
|
||||||
|
|
@ -201,13 +209,13 @@ export function CreateAutomationRuleDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FormLabel>Add Labels</FormLabel>
|
<FormLabel>{__('Add Labels')}</FormLabel>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<LabelCombobox
|
<LabelCombobox
|
||||||
value={selectedLabelIds}
|
value={selectedLabelIds}
|
||||||
onValueChange={setSelectedLabelIds}
|
onValueChange={setSelectedLabelIds}
|
||||||
labels={labels}
|
labels={labels}
|
||||||
placeholder="Select labels (optional)"
|
placeholder={__('Select labels (optional)')}
|
||||||
allowCreate={true}
|
allowCreate={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -234,7 +242,7 @@ export function CreateAutomationRuleDialog({
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import type { AutomationRule } from '@/types/automation-rule';
|
import type { AutomationRule } from '@/types/automation-rule';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -47,15 +48,20 @@ export function DeleteAutomationRuleDialog({
|
||||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete Automation Rule</AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
|
{__('Delete Automation Rule')}
|
||||||
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete "{rule.title}"? This
|
{__('Are you sure you want to delete "')}
|
||||||
action cannot be undone.
|
{rule.title}
|
||||||
|
{__(
|
||||||
|
'"? This\n action cannot be undone.',
|
||||||
|
)}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={isDeleting}>
|
<AlertDialogCancel disabled={isDeleting}>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
import type { AutomationRule } from '@/types/automation-rule';
|
import type { AutomationRule } from '@/types/automation-rule';
|
||||||
import type { Category } from '@/types/category';
|
import type { Category } from '@/types/category';
|
||||||
import type { Label as LabelType } from '@/types/label';
|
import type { Label as LabelType } from '@/types/label';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -133,22 +134,24 @@ export function EditAutomationRuleDialog({
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="overflow-x-hidden sm:max-w-[600px]">
|
<DialogContent className="overflow-x-hidden sm:max-w-[600px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Automation Rule</DialogTitle>
|
<DialogTitle>{__('Edit Automation Rule')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update the rule to automatically categorize transactions
|
{__(
|
||||||
and add labels.
|
'Update the rule to automatically categorize transactions\n and add labels.',
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="title">Title</Label>
|
<Label htmlFor="title">{__('Title')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Rule title"
|
placeholder={__('Rule title')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{errors.title && (
|
{errors.title && (
|
||||||
<p className="text-sm text-red-500">
|
<p className="text-sm text-red-500">
|
||||||
{errors.title}
|
{errors.title}
|
||||||
|
|
@ -157,7 +160,7 @@ export function EditAutomationRuleDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="priority">Priority</Label>
|
<Label htmlFor="priority">{__('Priority')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="priority"
|
id="priority"
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -167,8 +170,9 @@ export function EditAutomationRuleDialog({
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Lower numbers execute first
|
{__('Lower numbers execute first')}
|
||||||
</p>
|
</p>
|
||||||
{errors.priority && (
|
{errors.priority && (
|
||||||
<p className="text-sm text-red-500">
|
<p className="text-sm text-red-500">
|
||||||
|
|
@ -184,30 +188,32 @@ export function EditAutomationRuleDialog({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-4 rounded-md border p-4">
|
<div className="space-y-4 rounded-md border p-4">
|
||||||
<h4 className="font-medium">Actions</h4>
|
<h4 className="font-medium">{__('Actions')}</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
At least one action is required
|
{__('At least one action is required')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="category">Set Category</Label>
|
<Label htmlFor="category">
|
||||||
|
{__('Set Category')}
|
||||||
|
</Label>
|
||||||
<CategoryCombobox
|
<CategoryCombobox
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onValueChange={setCategoryId}
|
onValueChange={setCategoryId}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
placeholder="Select a category (optional)"
|
placeholder={__('Select a category (optional)')}
|
||||||
showUncategorized={false}
|
showUncategorized={false}
|
||||||
data-testid="action-category-select"
|
data-testid="action-category-select"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Add Labels</Label>
|
<Label>{__('Add Labels')}</Label>
|
||||||
<LabelCombobox
|
<LabelCombobox
|
||||||
value={selectedLabelIds}
|
value={selectedLabelIds}
|
||||||
onValueChange={setSelectedLabelIds}
|
onValueChange={setSelectedLabelIds}
|
||||||
labels={labels}
|
labels={labels}
|
||||||
placeholder="Select labels (optional)"
|
placeholder={__('Select labels (optional)')}
|
||||||
allowCreate={true}
|
allowCreate={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -233,7 +239,7 @@ export function EditAutomationRuleDialog({
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
OPERATOR_LABELS,
|
OPERATOR_LABELS,
|
||||||
type RuleStructure,
|
type RuleStructure,
|
||||||
} from '@/lib/rule-builder-utils';
|
} from '@/lib/rule-builder-utils';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { ChevronDown, Plus, Trash2, X } from 'lucide-react';
|
import { ChevronDown, Plus, Trash2, X } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -76,7 +77,7 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Conditions</Label>
|
<Label>{__('Conditions')}</Label>
|
||||||
{structure.groups.length > 1 && (
|
{structure.groups.length > 1 && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -85,7 +86,7 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
|
||||||
onClick={toggleGroupOperator}
|
onClick={toggleGroupOperator}
|
||||||
className="px-1.5 py-4"
|
className="px-1.5 py-4"
|
||||||
>
|
>
|
||||||
Groups joined by:{' '}
|
{__('Groups joined by:')}{' '}
|
||||||
<Badge variant="secondary" className="ml-2">
|
<Badge variant="secondary" className="ml-2">
|
||||||
{structure.groupOperator.toUpperCase()}
|
{structure.groupOperator.toUpperCase()}
|
||||||
<ChevronDown className="-mr-1 inline-block h-3 w-3" />
|
<ChevronDown className="-mr-1 inline-block h-3 w-3" />
|
||||||
|
|
@ -120,7 +121,9 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
|
||||||
data-testid="toggle-condition-operator"
|
data-testid="toggle-condition-operator"
|
||||||
>
|
>
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
Conditions joined by:{' '}
|
{__(
|
||||||
|
'Conditions joined by:',
|
||||||
|
)}{' '}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
@ -204,7 +207,7 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Condition
|
{__('Add Condition')}
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -219,7 +222,7 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
|
||||||
onClick={addGroup}
|
onClick={addGroup}
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Group
|
{__('Add Group')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
@ -314,7 +317,7 @@ function ConditionRow({
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({ ...condition, value: e.target.value })
|
onChange({ ...condition, value: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder="Value"
|
placeholder={__('Value')}
|
||||||
className="w-full sm:flex-1"
|
className="w-full sm:flex-1"
|
||||||
step={inputType === 'number' ? 'any' : undefined}
|
step={inputType === 'number' ? 'any' : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from '@/components/ui/breadcrumb';
|
} from '@/components/ui/breadcrumb';
|
||||||
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
|
|
||||||
|
|
@ -27,12 +28,12 @@ export function Breadcrumbs({
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
{isLast ? (
|
{isLast ? (
|
||||||
<BreadcrumbPage>
|
<BreadcrumbPage>
|
||||||
{item.title}
|
{__(item.title)}
|
||||||
</BreadcrumbPage>
|
</BreadcrumbPage>
|
||||||
) : (
|
) : (
|
||||||
<BreadcrumbLink asChild>
|
<BreadcrumbLink asChild>
|
||||||
<Link href={item.href}>
|
<Link href={item.href}>
|
||||||
{item.title}
|
{__(item.title)}
|
||||||
</Link>
|
</Link>
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,11 @@ import {
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { useLocale } from '@/hooks/use-locale';
|
||||||
import { Budget, getBudgetPeriodTypeLabel } from '@/types/budget';
|
import { Budget, getBudgetPeriodTypeLabel } from '@/types/budget';
|
||||||
import { formatCurrency } from '@/utils/currency';
|
import { formatCurrency } from '@/utils/currency';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import { ArrowRight, Calendar } from 'lucide-react';
|
import { ArrowRight, Calendar } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
@ -21,6 +24,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BudgetListCard({ budget, currencyCode }: Props) {
|
export function BudgetListCard({ budget, currencyCode }: Props) {
|
||||||
|
const locale = useLocale();
|
||||||
const currentPeriod = budget.periods?.[0];
|
const currentPeriod = budget.periods?.[0];
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
|
|
@ -53,19 +57,13 @@ export function BudgetListCard({ budget, currencyCode }: Props) {
|
||||||
}, [currentPeriod]);
|
}, [currentPeriod]);
|
||||||
|
|
||||||
const periodLabel = useMemo(() => {
|
const periodLabel = useMemo(() => {
|
||||||
if (!currentPeriod) return 'No active period';
|
if (!currentPeriod) return __('No active period');
|
||||||
|
|
||||||
const start = new Date(currentPeriod.start_date).toLocaleDateString(
|
const start = formatDate(currentPeriod.start_date, 'MMM d', locale);
|
||||||
'en-US',
|
const end = formatDate(currentPeriod.end_date, 'MMM d', locale);
|
||||||
{ month: 'short', day: 'numeric' },
|
|
||||||
);
|
|
||||||
const end = new Date(currentPeriod.end_date).toLocaleDateString(
|
|
||||||
'en-US',
|
|
||||||
{ month: 'short', day: 'numeric' },
|
|
||||||
);
|
|
||||||
|
|
||||||
return `${start} - ${end}`;
|
return `${start} - ${end}`;
|
||||||
}, [currentPeriod]);
|
}, [currentPeriod, locale]);
|
||||||
|
|
||||||
const statusColor = useMemo(() => {
|
const statusColor = useMemo(() => {
|
||||||
if (stats.percentageUsed >= 100)
|
if (stats.percentageUsed >= 100)
|
||||||
|
|
@ -78,7 +76,7 @@ export function BudgetListCard({ budget, currencyCode }: Props) {
|
||||||
const trackingLabel = useMemo(() => {
|
const trackingLabel = useMemo(() => {
|
||||||
if (budget.category) return budget.category.name;
|
if (budget.category) return budget.category.name;
|
||||||
if (budget.label) return budget.label.name;
|
if (budget.label) return budget.label.name;
|
||||||
return 'No tracking';
|
return __('No tracking');
|
||||||
}, [budget]);
|
}, [budget]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -93,16 +91,19 @@ export function BudgetListCard({ budget, currencyCode }: Props) {
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{getBudgetPeriodTypeLabel(budget.period_type)}
|
{__(getBudgetPeriodTypeLabel(budget.period_type))}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Spent</span>
|
<span className="text-muted-foreground">
|
||||||
|
{__('Spent')}
|
||||||
|
</span>
|
||||||
<span className={statusColor}>
|
<span className={statusColor}>
|
||||||
{formatCurrency(stats.totalSpent, currencyCode)} of{' '}
|
{formatCurrency(stats.totalSpent, currencyCode)}{' '}
|
||||||
|
{__('of')}{' '}
|
||||||
{formatCurrency(stats.totalAllocated, currencyCode)}
|
{formatCurrency(stats.totalAllocated, currencyCode)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -110,8 +111,11 @@ export function BudgetListCard({ budget, currencyCode }: Props) {
|
||||||
value={Math.min(stats.percentageUsed, 100)}
|
value={Math.min(stats.percentageUsed, 100)}
|
||||||
className="h-2"
|
className="h-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Remaining</span>
|
<span className="text-muted-foreground">
|
||||||
|
{__('Remaining')}
|
||||||
|
</span>
|
||||||
<span className={statusColor}>
|
<span className={statusColor}>
|
||||||
{formatCurrency(stats.remaining, currencyCode)}
|
{formatCurrency(stats.remaining, currencyCode)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -120,7 +124,8 @@ export function BudgetListCard({ budget, currencyCode }: Props) {
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t pt-4">
|
<div className="flex items-center justify-between border-t pt-4">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Tracking: {trackingLabel}
|
{__('Tracking:')}
|
||||||
|
{trackingLabel}
|
||||||
</span>
|
</span>
|
||||||
<Link href={show({ budget: budget.id }).url}>
|
<Link href={show({ budget: budget.id }).url}>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -128,7 +133,8 @@ export function BudgetListCard({ budget, currencyCode }: Props) {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
View Details
|
{__('View Details')}
|
||||||
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,11 @@ import {
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
} from '@/components/ui/chart';
|
} from '@/components/ui/chart';
|
||||||
|
import { useLocale } from '@/hooks/use-locale';
|
||||||
import { BudgetPeriod } from '@/types/budget';
|
import { BudgetPeriod } from '@/types/budget';
|
||||||
import { formatCurrency } from '@/utils/currency';
|
import { formatCurrency } from '@/utils/currency';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Area, AreaChart, Line, XAxis } from 'recharts';
|
import { Area, AreaChart, Line, XAxis } from 'recharts';
|
||||||
|
|
||||||
|
|
@ -40,6 +43,7 @@ interface CustomTooltipProps {
|
||||||
label?: string | number;
|
label?: string | number;
|
||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
hasPreviousPeriod: boolean;
|
hasPreviousPeriod: boolean;
|
||||||
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomTooltip({
|
function CustomTooltip({
|
||||||
|
|
@ -47,6 +51,7 @@ function CustomTooltip({
|
||||||
payload,
|
payload,
|
||||||
currencyCode,
|
currencyCode,
|
||||||
hasPreviousPeriod,
|
hasPreviousPeriod,
|
||||||
|
locale,
|
||||||
}: CustomTooltipProps) {
|
}: CustomTooltipProps) {
|
||||||
if (!active || !payload || !payload.length) {
|
if (!active || !payload || !payload.length) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -64,21 +69,21 @@ function CustomTooltip({
|
||||||
<p className="mb-2 text-sm font-medium">
|
<p className="mb-2 text-sm font-medium">
|
||||||
{hasPreviousPeriod
|
{hasPreviousPeriod
|
||||||
? `Day ${data.day}`
|
? `Day ${data.day}`
|
||||||
: new Date(data.date).toLocaleDateString('en-US', {
|
: formatDate(data.date, 'MMM d, yyyy', locale)}
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
<div className="flex items-center justify-between gap-8">
|
<div className="flex items-center justify-between gap-8">
|
||||||
<span className="text-muted-foreground">Allocated:</span>
|
<span className="text-muted-foreground">
|
||||||
|
{__('Allocated:')}
|
||||||
|
</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatCurrency(allocated, currencyCode)}
|
{formatCurrency(allocated, currencyCode)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-8">
|
<div className="flex items-center justify-between gap-8">
|
||||||
<span className="text-muted-foreground">Spent:</span>
|
<span className="text-muted-foreground">
|
||||||
|
{__('Spent:')}
|
||||||
|
</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatCurrency(spent, currencyCode)}
|
{formatCurrency(spent, currencyCode)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -86,7 +91,7 @@ function CustomTooltip({
|
||||||
{hasPreviousPeriod && data.prevSpent !== undefined && (
|
{hasPreviousPeriod && data.prevSpent !== undefined && (
|
||||||
<div className="flex items-center justify-between gap-8">
|
<div className="flex items-center justify-between gap-8">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
Last period:
|
{__('Last period:')}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium text-muted-foreground">
|
<span className="font-medium text-muted-foreground">
|
||||||
{formatCurrency(data.prevSpent, currencyCode)}
|
{formatCurrency(data.prevSpent, currencyCode)}
|
||||||
|
|
@ -95,7 +100,7 @@ function CustomTooltip({
|
||||||
)}
|
)}
|
||||||
<div className="border-t pt-1">
|
<div className="border-t pt-1">
|
||||||
<div className="flex items-center justify-between gap-8">
|
<div className="flex items-center justify-between gap-8">
|
||||||
<span className="font-medium">Available:</span>
|
<span className="font-medium">{__('Available:')}</span>
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{percentage}% /
|
{percentage}% /
|
||||||
|
|
@ -135,6 +140,7 @@ export function BudgetSpendingChart({
|
||||||
budgetName,
|
budgetName,
|
||||||
currencyCode,
|
currencyCode,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const locale = useLocale();
|
||||||
const hasPreviousPeriod = !!previousPeriod;
|
const hasPreviousPeriod = !!previousPeriod;
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
|
|
@ -212,23 +218,18 @@ export function BudgetSpendingChart({
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
const periodLabel = useMemo(() => {
|
const periodLabel = useMemo(() => {
|
||||||
const start = new Date(currentPeriod.start_date).toLocaleDateString(
|
const start = formatDate(currentPeriod.start_date, 'MMM d', locale);
|
||||||
'en-US',
|
const end = formatDate(currentPeriod.end_date, 'MMM d, yyyy', locale);
|
||||||
{ month: 'short', day: 'numeric' },
|
|
||||||
);
|
|
||||||
const end = new Date(currentPeriod.end_date).toLocaleDateString(
|
|
||||||
'en-US',
|
|
||||||
{ month: 'short', day: 'numeric', year: 'numeric' },
|
|
||||||
);
|
|
||||||
return `${start} - ${end}`;
|
return `${start} - ${end}`;
|
||||||
}, [currentPeriod]);
|
}, [currentPeriod, locale]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Budget Spending</CardTitle>
|
<CardTitle>{__('Budget Spending')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Tracking spending for {budgetName} · {periodLabel}
|
{__('Tracking spending for')}
|
||||||
|
{budgetName} · {periodLabel}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-6 pt-0">
|
<CardContent className="px-6 pt-0">
|
||||||
|
|
@ -254,11 +255,13 @@ export function BudgetSpendingChart({
|
||||||
stopColor="var(--color-spent)"
|
stopColor="var(--color-spent)"
|
||||||
stopOpacity={0.8}
|
stopOpacity={0.8}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<stop
|
<stop
|
||||||
offset="50%"
|
offset="50%"
|
||||||
stopColor="var(--color-spent)"
|
stopColor="var(--color-spent)"
|
||||||
stopOpacity={0.4}
|
stopOpacity={0.4}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<stop
|
<stop
|
||||||
offset="100%"
|
offset="100%"
|
||||||
stopColor="var(--color-spent)"
|
stopColor="var(--color-spent)"
|
||||||
|
|
@ -277,11 +280,13 @@ export function BudgetSpendingChart({
|
||||||
stopColor="var(--color-allocated)"
|
stopColor="var(--color-allocated)"
|
||||||
stopOpacity={0.4}
|
stopOpacity={0.4}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<stop
|
<stop
|
||||||
offset="50%"
|
offset="50%"
|
||||||
stopColor="var(--color-allocated)"
|
stopColor="var(--color-allocated)"
|
||||||
stopOpacity={0.2}
|
stopOpacity={0.2}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<stop
|
<stop
|
||||||
offset="100%"
|
offset="100%"
|
||||||
stopColor="var(--color-allocated)"
|
stopColor="var(--color-allocated)"
|
||||||
|
|
@ -298,21 +303,20 @@ export function BudgetSpendingChart({
|
||||||
if (hasPreviousPeriod) {
|
if (hasPreviousPeriod) {
|
||||||
return `Day ${value}`;
|
return `Day ${value}`;
|
||||||
}
|
}
|
||||||
const date = new Date(value);
|
return formatDate(value, 'MMM d', locale);
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={
|
content={
|
||||||
<CustomTooltip
|
<CustomTooltip
|
||||||
currencyCode={currencyCode}
|
currencyCode={currencyCode}
|
||||||
hasPreviousPeriod={hasPreviousPeriod}
|
hasPreviousPeriod={hasPreviousPeriod}
|
||||||
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Area
|
<Area
|
||||||
dataKey="allocated"
|
dataKey="allocated"
|
||||||
type="basis"
|
type="basis"
|
||||||
|
|
@ -323,6 +327,7 @@ export function BudgetSpendingChart({
|
||||||
activeDot={{ r: 6 }}
|
activeDot={{ r: 6 }}
|
||||||
fillOpacity={1}
|
fillOpacity={1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Area
|
<Area
|
||||||
dataKey="spent"
|
dataKey="spent"
|
||||||
type="basis"
|
type="basis"
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
} from '@/types/budget';
|
} from '@/types/budget';
|
||||||
import { Category } from '@/types/category';
|
import { Category } from '@/types/category';
|
||||||
import { Label } from '@/types/label';
|
import { Label } from '@/types/label';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { router, usePage } from '@inertiajs/react';
|
import { router, usePage } from '@inertiajs/react';
|
||||||
import { Plus, X } from 'lucide-react';
|
import { Plus, X } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
@ -125,7 +126,7 @@ export function CreateBudgetDialog({
|
||||||
<CardContent className="flex h-full items-center justify-center">
|
<CardContent className="flex h-full items-center justify-center">
|
||||||
<div className="flex flex-row items-center justify-center gap-1">
|
<div className="flex flex-row items-center justify-center gap-1">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create Budget
|
{__('Create Budget')}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -133,26 +134,32 @@ export function CreateBudgetDialog({
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[600px]">
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[600px]">
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create Budget</DialogTitle>
|
<DialogTitle>{__('Create Budget')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Set up a spending limit for a category or label.
|
{__(
|
||||||
|
'Set up a spending limit for a category or label.',
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6 py-4">
|
<div className="space-y-6 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<UILabel htmlFor="name">Budget Name</UILabel>
|
<UILabel htmlFor="name">
|
||||||
|
{__('Budget Name')}
|
||||||
|
</UILabel>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g., Padel Budget"
|
placeholder={__('e.g., Padel Budget')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<UILabel htmlFor="period-type">Period Type</UILabel>
|
<UILabel htmlFor="period-type">
|
||||||
|
{__('Period Type')}
|
||||||
|
</UILabel>
|
||||||
<Select
|
<Select
|
||||||
value={periodType}
|
value={periodType}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|
@ -175,7 +182,7 @@ export function CreateBudgetDialog({
|
||||||
{periodType === 'custom' && (
|
{periodType === 'custom' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<UILabel htmlFor="period-duration">
|
<UILabel htmlFor="period-duration">
|
||||||
Period Duration (days)
|
{__('Period Duration (days)')}
|
||||||
</UILabel>
|
</UILabel>
|
||||||
<Input
|
<Input
|
||||||
id="period-duration"
|
id="period-duration"
|
||||||
|
|
@ -211,6 +218,7 @@ export function CreateBudgetDialog({
|
||||||
setPeriodStartDay(parseInt(e.target.value))
|
setPeriodStartDay(parseInt(e.target.value))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{periodType === 'monthly'
|
{periodType === 'monthly'
|
||||||
? 'Day of the month when the period starts (1-31)'
|
? 'Day of the month when the period starts (1-31)'
|
||||||
|
|
@ -230,7 +238,7 @@ export function CreateBudgetDialog({
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<UILabel htmlFor="category">
|
<UILabel htmlFor="category">
|
||||||
Category (Optional)
|
{__('Category (Optional)')}
|
||||||
</UILabel>
|
</UILabel>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -241,7 +249,11 @@ export function CreateBudgetDialog({
|
||||||
id="category"
|
id="category"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<SelectValue placeholder="Select a category" />
|
<SelectValue
|
||||||
|
placeholder={__(
|
||||||
|
'Select a category',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{allCategories.map((category) => (
|
{allCategories.map((category) => (
|
||||||
|
|
@ -271,7 +283,7 @@ export function CreateBudgetDialog({
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<UILabel htmlFor="label">
|
<UILabel htmlFor="label">
|
||||||
Label (Optional)
|
{__('Label (Optional)')}
|
||||||
</UILabel>
|
</UILabel>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -284,7 +296,11 @@ export function CreateBudgetDialog({
|
||||||
id="label"
|
id="label"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<SelectValue placeholder="Select a label" />
|
<SelectValue
|
||||||
|
placeholder={__(
|
||||||
|
'Select a label',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{allLabels.map((label) => (
|
{allLabels.map((label) => (
|
||||||
|
|
@ -311,14 +327,15 @@ export function CreateBudgetDialog({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Select at least a category or a label to
|
{__(
|
||||||
track.
|
'Select at least a category or a label to\n track.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<UILabel htmlFor="allocated-amount">
|
<UILabel htmlFor="allocated-amount">
|
||||||
Allocated Amount
|
{__('Allocated Amount')}
|
||||||
</UILabel>
|
</UILabel>
|
||||||
<AmountInput
|
<AmountInput
|
||||||
id="allocated-amount"
|
id="allocated-amount"
|
||||||
|
|
@ -328,14 +345,17 @@ export function CreateBudgetDialog({
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
How much do you want to budget per period?
|
{__(
|
||||||
|
'How much do you want to budget per period?',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<UILabel htmlFor="rollover">
|
<UILabel htmlFor="rollover">
|
||||||
Rollover Type
|
{__('Rollover Type')}
|
||||||
</UILabel>
|
</UILabel>
|
||||||
<Select
|
<Select
|
||||||
value={rolloverType}
|
value={rolloverType}
|
||||||
|
|
@ -369,7 +389,7 @@ export function CreateBudgetDialog({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Budget } from '@/types/budget';
|
import { Budget } from '@/types/budget';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -46,17 +47,18 @@ export function DeleteBudgetDialog({
|
||||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete Budget</AlertDialogTitle>
|
<AlertDialogTitle>{__('Delete Budget')}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete "{budget.name}"? This
|
{__('Are you sure you want to delete "')}
|
||||||
action cannot be undone. All budget periods,
|
{budget.name}
|
||||||
allocations, and transaction assignments will be
|
{__(
|
||||||
permanently removed.
|
'"? This\n action cannot be undone. All budget periods,\n allocations, and transaction assignments will be\n permanently removed.',
|
||||||
|
)}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={isDeleting}>
|
<AlertDialogCancel disabled={isDeleting}>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
ROLLOVER_TYPES,
|
ROLLOVER_TYPES,
|
||||||
RolloverType,
|
RolloverType,
|
||||||
} from '@/types/budget';
|
} from '@/types/budget';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -102,28 +103,31 @@ export function EditBudgetDialog({
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[600px]">
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[600px]">
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Budget</DialogTitle>
|
<DialogTitle>{__('Edit Budget')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update your budget settings. To change the allocated
|
{__(
|
||||||
amount or tracking, use the budget page directly.
|
'Update your budget settings. To change the allocated\n amount or tracking, use the budget page directly.',
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Budget Name</Label>
|
<Label htmlFor="name">{__('Budget Name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={name}
|
value={name}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g., Monthly Budget"
|
placeholder={__('e.g., Monthly Budget')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="period-type">Period Type</Label>
|
<Label htmlFor="period-type">
|
||||||
|
{__('Period Type')}
|
||||||
|
</Label>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<Select
|
<Select
|
||||||
value={periodType}
|
value={periodType}
|
||||||
|
|
@ -149,7 +153,7 @@ export function EditBudgetDialog({
|
||||||
{periodType === 'custom' && (
|
{periodType === 'custom' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="period-duration">
|
<Label htmlFor="period-duration">
|
||||||
Period Duration (days)
|
{__('Period Duration (days)')}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
disabled
|
disabled
|
||||||
|
|
@ -189,6 +193,7 @@ export function EditBudgetDialog({
|
||||||
setPeriodStartDay(parseInt(e.target.value))
|
setPeriodStartDay(parseInt(e.target.value))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{periodType === 'monthly'
|
{periodType === 'monthly'
|
||||||
? 'Day of the month when the period starts (1-31)'
|
? 'Day of the month when the period starts (1-31)'
|
||||||
|
|
@ -201,7 +206,7 @@ export function EditBudgetDialog({
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="allocated-amount">
|
<Label htmlFor="allocated-amount">
|
||||||
Allocated Amount
|
{__('Allocated Amount')}
|
||||||
</Label>
|
</Label>
|
||||||
<AmountInput
|
<AmountInput
|
||||||
id="allocated-amount"
|
id="allocated-amount"
|
||||||
|
|
@ -211,14 +216,18 @@ export function EditBudgetDialog({
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This will update the allocated amount for the
|
{__(
|
||||||
current and future periods.
|
'This will update the allocated amount for the\n current and future periods.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="rollover">Rollover Type</Label>
|
<Label htmlFor="rollover">
|
||||||
|
{__('Rollover Type')}
|
||||||
|
</Label>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<Select
|
<Select
|
||||||
disabled
|
disabled
|
||||||
|
|
@ -253,7 +262,7 @@ export function EditBudgetDialog({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSubmitting || !name}>
|
<Button type="submit" disabled={isSubmitting || !name}>
|
||||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { Progress } from '@/components/ui/progress';
|
||||||
import { BreakdownData } from '@/hooks/use-cashflow-data';
|
import { BreakdownData } from '@/hooks/use-cashflow-data';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getCategoryColorClasses } from '@/types/category';
|
import { getCategoryColorClasses } from '@/types/category';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import * as Icons from 'lucide-react';
|
import * as Icons from 'lucide-react';
|
||||||
import { LucideIcon } from 'lucide-react';
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -38,13 +39,16 @@ export function BreakdownCard({
|
||||||
loading,
|
loading,
|
||||||
currency = 'USD',
|
currency = 'USD',
|
||||||
}: BreakdownCardProps) {
|
}: BreakdownCardProps) {
|
||||||
const title = type === 'income' ? 'Income Sources' : 'Expense Categories';
|
const title =
|
||||||
|
type === 'income' ? __('Income Sources') : __('Expense Categories');
|
||||||
const description =
|
const description =
|
||||||
type === 'income'
|
type === 'income'
|
||||||
? 'Where your money comes from'
|
? __('Where your money comes from')
|
||||||
: 'Where your money goes';
|
: __('Where your money goes');
|
||||||
const emptyMessage =
|
const emptyMessage =
|
||||||
type === 'income' ? 'No income this period' : 'No expenses this period';
|
type === 'income'
|
||||||
|
? __('No income this period')
|
||||||
|
: __('No expenses this period');
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { CashflowSummary } from '@/hooks/use-cashflow-data';
|
import { CashflowSummary } from '@/hooks/use-cashflow-data';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { ArrowDown, ArrowUp, TrendingDown, TrendingUp } from 'lucide-react';
|
import { ArrowDown, ArrowUp, TrendingDown, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
interface NetCashflowCardProps {
|
interface NetCashflowCardProps {
|
||||||
|
|
@ -28,7 +29,7 @@ export function NetCashflowCard({
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle className="text-sm font-medium">
|
||||||
Net Cashflow
|
{__('Net Cashflow')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -47,9 +48,9 @@ export function NetCashflowCard({
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle className="text-sm font-medium">
|
||||||
Net Cashflow
|
{__('Net Cashflow')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Income minus expenses</CardDescription>
|
<CardDescription>{__('Income minus expenses')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
|
|
@ -109,13 +110,15 @@ export function NetCashflowCard({
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
vs last period
|
{__('vs last period')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 grid grid-cols-2 gap-4 border-t pt-3">
|
<div className="mt-3 grid grid-cols-2 gap-4 border-t pt-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Income</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{__('Income')}
|
||||||
|
</p>
|
||||||
<AmountDisplay
|
<AmountDisplay
|
||||||
amountInCents={current.income}
|
amountInCents={current.income}
|
||||||
currencyCode={currency}
|
currencyCode={currency}
|
||||||
|
|
@ -127,7 +130,7 @@ export function NetCashflowCard({
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Expenses
|
{__('Expenses')}
|
||||||
</p>
|
</p>
|
||||||
<AmountDisplay
|
<AmountDisplay
|
||||||
amountInCents={current.expense}
|
amountInCents={current.expense}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { addMonths, format, isSameMonth, subMonths } from 'date-fns';
|
import { useLocale } from '@/hooks/use-locale';
|
||||||
|
import { formatMonthYear } from '@/utils/date';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
|
import { addMonths, isSameMonth, subMonths } from 'date-fns';
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
interface PeriodNavigationProps {
|
interface PeriodNavigationProps {
|
||||||
|
|
@ -11,6 +14,7 @@ export function PeriodNavigation({
|
||||||
currentDate,
|
currentDate,
|
||||||
onDateChange,
|
onDateChange,
|
||||||
}: PeriodNavigationProps) {
|
}: PeriodNavigationProps) {
|
||||||
|
const locale = useLocale();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const isCurrentMonth = isSameMonth(currentDate, now);
|
const isCurrentMonth = isSameMonth(currentDate, now);
|
||||||
|
|
||||||
|
|
@ -32,7 +36,7 @@ export function PeriodNavigation({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={handlePrevMonth}
|
onClick={handlePrevMonth}
|
||||||
aria-label="Previous month"
|
aria-label={__('Previous month')}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-4" />
|
<ChevronLeft className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -41,7 +45,7 @@ export function PeriodNavigation({
|
||||||
onClick={handleCurrentMonth}
|
onClick={handleCurrentMonth}
|
||||||
className="min-w-[140px] rounded-md px-3 py-1.5 text-center text-sm font-medium hover:bg-accent"
|
className="min-w-[140px] rounded-md px-3 py-1.5 text-center text-sm font-medium hover:bg-accent"
|
||||||
>
|
>
|
||||||
{format(currentDate, 'MMMM yyyy')}
|
{formatMonthYear(currentDate, locale)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -49,7 +53,7 @@ export function PeriodNavigation({
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={handleNextMonth}
|
onClick={handleNextMonth}
|
||||||
disabled={isCurrentMonth}
|
disabled={isCurrentMonth}
|
||||||
aria-label="Next month"
|
aria-label={__('Next month')}
|
||||||
>
|
>
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { CashflowSummary } from '@/hooks/use-cashflow-data';
|
import { CashflowSummary } from '@/hooks/use-cashflow-data';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { TrendingDown, TrendingUp } from 'lucide-react';
|
import { TrendingDown, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
interface SavingsRateCardProps {
|
interface SavingsRateCardProps {
|
||||||
|
|
@ -25,7 +26,7 @@ export function SavingsRateCard({
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle className="text-sm font-medium">
|
||||||
Savings Rate
|
{__('Savings Rate')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -53,9 +54,11 @@ export function SavingsRateCard({
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle className="text-sm font-medium">
|
||||||
Savings Rate
|
{__('Savings Rate')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Percentage of income saved</CardDescription>
|
<CardDescription>
|
||||||
|
{__('Percentage of income saved')}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
|
|
@ -83,12 +86,12 @@ export function SavingsRateCard({
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
{current.savings_rate >= 20
|
{current.savings_rate >= 20
|
||||||
? "Great job! You're saving well."
|
? __("Great job! You're saving well.")
|
||||||
: current.savings_rate >= 10
|
: current.savings_rate >= 10
|
||||||
? 'Good progress on your savings.'
|
? __('Good progress on your savings.')
|
||||||
: current.savings_rate >= 0
|
: current.savings_rate >= 0
|
||||||
? 'Consider saving more if possible.'
|
? __('Consider saving more if possible.')
|
||||||
: 'Spending exceeds income this period.'}
|
: __('Spending exceeds income this period.')}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
CATEGORY_TYPES,
|
CATEGORY_TYPES,
|
||||||
getCategoryColorClasses,
|
getCategoryColorClasses,
|
||||||
} from '@/types/category';
|
} from '@/types/category';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Form } from '@inertiajs/react';
|
import { Form } from '@inertiajs/react';
|
||||||
import * as Icons from 'lucide-react';
|
import * as Icons from 'lucide-react';
|
||||||
import { Info } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
|
|
@ -41,13 +42,15 @@ export function CreateCategoryDialog({
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button>Create Category</Button>
|
<Button>{__('Create Category')}</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create Category</DialogTitle>
|
<DialogTitle>{__('Create Category')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Add a new category to organize your transactions.
|
{__(
|
||||||
|
'Add a new category to organize your transactions.',
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form
|
<Form
|
||||||
|
|
@ -61,13 +64,14 @@ export function CreateCategoryDialog({
|
||||||
{({ errors, processing }) => (
|
{({ errors, processing }) => (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor="name">{__('Name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Category name"
|
placeholder={__('Category name')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<p className="text-sm text-red-500">
|
<p className="text-sm text-red-500">
|
||||||
{errors.name}
|
{errors.name}
|
||||||
|
|
@ -76,10 +80,12 @@ export function CreateCategoryDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="icon">Icon</Label>
|
<Label htmlFor="icon">{__('Icon')}</Label>
|
||||||
<Select name="icon" required>
|
<Select name="icon" required>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select an icon" />
|
<SelectValue
|
||||||
|
placeholder={__('Select an icon')}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{CATEGORY_ICONS.map((iconName) => {
|
{CATEGORY_ICONS.map((iconName) => {
|
||||||
|
|
@ -108,10 +114,12 @@ export function CreateCategoryDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="color">Color</Label>
|
<Label htmlFor="color">{__('Color')}</Label>
|
||||||
<Select name="color" required>
|
<Select name="color" required>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a color" />
|
<SelectValue
|
||||||
|
placeholder={__('Select a color')}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{CATEGORY_COLORS.map((color) => {
|
{CATEGORY_COLORS.map((color) => {
|
||||||
|
|
@ -126,7 +134,7 @@ export function CreateCategoryDialog({
|
||||||
<Badge
|
<Badge
|
||||||
className={`${colorClasses.bg} ${colorClasses.text}`}
|
className={`${colorClasses.bg} ${colorClasses.text}`}
|
||||||
>
|
>
|
||||||
{color}
|
{__(color)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -142,14 +150,16 @@ export function CreateCategoryDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type">Type</Label>
|
<Label htmlFor="type">{__('Type')}</Label>
|
||||||
<Select
|
<Select
|
||||||
name="type"
|
name="type"
|
||||||
required
|
required
|
||||||
onValueChange={setSelectedType}
|
onValueChange={setSelectedType}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a type" />
|
<SelectValue
|
||||||
|
placeholder={__('Select a type')}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{CATEGORY_TYPES.map((type) => (
|
{CATEGORY_TYPES.map((type) => (
|
||||||
|
|
@ -169,11 +179,9 @@ export function CreateCategoryDialog({
|
||||||
<Alert>
|
<Alert>
|
||||||
<Info className="h-4 w-4 opacity-50" />
|
<Info className="h-4 w-4 opacity-50" />
|
||||||
<AlertDescription className="text-sm">
|
<AlertDescription className="text-sm">
|
||||||
Transactions in this category will
|
{__(
|
||||||
not be counted in top expenses or
|
'Transactions in this category will\n not be counted in top expenses or\n income. Transfer categories are\n mainly used for transactions between\n accounts.',
|
||||||
income. Transfer categories are
|
)}
|
||||||
mainly used for transactions between
|
|
||||||
accounts.
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
@ -186,7 +194,7 @@ export function CreateCategoryDialog({
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={processing}>
|
<Button type="submit" disabled={processing}>
|
||||||
{processing ? 'Creating...' : 'Create'}
|
{processing ? 'Creating...' : 'Create'}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { type Category } from '@/types/category';
|
import { type Category } from '@/types/category';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Form } from '@inertiajs/react';
|
import { Form } from '@inertiajs/react';
|
||||||
|
|
||||||
interface DeleteCategoryDialogProps {
|
interface DeleteCategoryDialogProps {
|
||||||
|
|
@ -28,10 +29,13 @@ export function DeleteCategoryDialog({
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Category</DialogTitle>
|
<DialogTitle>{__('Delete Category')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete "{category.name}"? This
|
{__('Are you sure you want to delete "')}
|
||||||
action cannot be undone.
|
{category.name}
|
||||||
|
{__(
|
||||||
|
'"? This\n action cannot be undone.',
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form
|
<Form
|
||||||
|
|
@ -49,7 +53,7 @@ export function DeleteCategoryDialog({
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
getCategoryColorClasses,
|
getCategoryColorClasses,
|
||||||
type Category,
|
type Category,
|
||||||
} from '@/types/category';
|
} from '@/types/category';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Form } from '@inertiajs/react';
|
import { Form } from '@inertiajs/react';
|
||||||
import * as Icons from 'lucide-react';
|
import * as Icons from 'lucide-react';
|
||||||
import { Info } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
|
|
@ -49,9 +50,9 @@ export function EditCategoryDialog({
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Category</DialogTitle>
|
<DialogTitle>{__('Edit Category')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update the category information.
|
{__('Update the category information.')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form
|
<Form
|
||||||
|
|
@ -65,14 +66,15 @@ export function EditCategoryDialog({
|
||||||
{({ errors, processing }) => (
|
{({ errors, processing }) => (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor="name">{__('Name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
defaultValue={category.name}
|
defaultValue={category.name}
|
||||||
placeholder="Category name"
|
placeholder={__('Category name')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<p className="text-sm text-red-500">
|
<p className="text-sm text-red-500">
|
||||||
{errors.name}
|
{errors.name}
|
||||||
|
|
@ -81,14 +83,16 @@ export function EditCategoryDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="icon">Icon</Label>
|
<Label htmlFor="icon">{__('Icon')}</Label>
|
||||||
<Select
|
<Select
|
||||||
name="icon"
|
name="icon"
|
||||||
defaultValue={category.icon}
|
defaultValue={category.icon}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select an icon" />
|
<SelectValue
|
||||||
|
placeholder={__('Select an icon')}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{CATEGORY_ICONS.map((iconName) => {
|
{CATEGORY_ICONS.map((iconName) => {
|
||||||
|
|
@ -117,14 +121,16 @@ export function EditCategoryDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="color">Color</Label>
|
<Label htmlFor="color">{__('Color')}</Label>
|
||||||
<Select
|
<Select
|
||||||
name="color"
|
name="color"
|
||||||
defaultValue={category.color}
|
defaultValue={category.color}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a color" />
|
<SelectValue
|
||||||
|
placeholder={__('Select a color')}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{CATEGORY_COLORS.map((color) => {
|
{CATEGORY_COLORS.map((color) => {
|
||||||
|
|
@ -139,7 +145,7 @@ export function EditCategoryDialog({
|
||||||
<Badge
|
<Badge
|
||||||
className={`${colorClasses.bg} ${colorClasses.text}`}
|
className={`${colorClasses.bg} ${colorClasses.text}`}
|
||||||
>
|
>
|
||||||
{color}
|
{__(color)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -155,7 +161,7 @@ export function EditCategoryDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type">Type</Label>
|
<Label htmlFor="type">{__('Type')}</Label>
|
||||||
<Select
|
<Select
|
||||||
name="type"
|
name="type"
|
||||||
defaultValue={category.type}
|
defaultValue={category.type}
|
||||||
|
|
@ -163,7 +169,9 @@ export function EditCategoryDialog({
|
||||||
onValueChange={setSelectedType}
|
onValueChange={setSelectedType}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a type" />
|
<SelectValue
|
||||||
|
placeholder={__('Select a type')}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{CATEGORY_TYPES.map((type) => (
|
{CATEGORY_TYPES.map((type) => (
|
||||||
|
|
@ -183,11 +191,9 @@ export function EditCategoryDialog({
|
||||||
<Alert>
|
<Alert>
|
||||||
<Info className="h-4 w-4 opacity-50" />
|
<Info className="h-4 w-4 opacity-50" />
|
||||||
<AlertDescription className="text-sm">
|
<AlertDescription className="text-sm">
|
||||||
Transactions in this category will
|
{__(
|
||||||
not be counted in top expenses or
|
'Transactions in this category will\n not be counted in top expenses or\n income. Transfer categories are\n mainly used for transactions between\n accounts.',
|
||||||
income. Transfer categories are
|
)}
|
||||||
mainly used for transactions between
|
|
||||||
accounts.
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
@ -200,10 +206,12 @@ export function EditCategoryDialog({
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={processing}>
|
<Button type="submit" disabled={processing}>
|
||||||
{processing ? 'Updating...' : 'Update'}
|
{processing
|
||||||
|
? __('Updating...')
|
||||||
|
: __('Update')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ import {
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
|
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
|
||||||
import { TrendDataPoint } from '@/hooks/use-cashflow-data';
|
import { TrendDataPoint } from '@/hooks/use-cashflow-data';
|
||||||
|
import { useLocale } from '@/hooks/use-locale';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { formatCompactNumber, formatMonthFromYearMonth } from '@/utils/date';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
|
|
@ -28,21 +31,6 @@ interface CashflowTrendChartProps {
|
||||||
currency?: string;
|
currency?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartConfig: ChartConfig = {
|
|
||||||
income: {
|
|
||||||
label: 'Income',
|
|
||||||
color: 'var(--color-chart-2)',
|
|
||||||
},
|
|
||||||
expense: {
|
|
||||||
label: 'Expenses',
|
|
||||||
color: 'var(--color-chart-5)',
|
|
||||||
},
|
|
||||||
net: {
|
|
||||||
label: 'Net',
|
|
||||||
color: 'var(--color-chart-1)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TooltipPayloadItem {
|
interface TooltipPayloadItem {
|
||||||
dataKey?: string;
|
dataKey?: string;
|
||||||
value?: number;
|
value?: number;
|
||||||
|
|
@ -57,24 +45,13 @@ interface CustomTooltipProps {
|
||||||
currency?: string;
|
currency?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMonth(yearMonth: string): string {
|
|
||||||
const [year, month] = yearMonth.split('-');
|
|
||||||
const date = new Date(parseInt(year), parseInt(month) - 1);
|
|
||||||
const isCurrentYear = date.getFullYear() === new Date().getFullYear();
|
|
||||||
|
|
||||||
return date.toLocaleDateString(
|
|
||||||
'en-US',
|
|
||||||
isCurrentYear
|
|
||||||
? { month: 'short' }
|
|
||||||
: { year: '2-digit', month: 'short' },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CustomTooltip({
|
function CustomTooltip({
|
||||||
active,
|
active,
|
||||||
payload,
|
payload,
|
||||||
currency = 'USD',
|
currency = 'USD',
|
||||||
}: CustomTooltipProps) {
|
}: CustomTooltipProps) {
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
|
|
||||||
const data = payload[0].payload;
|
const data = payload[0].payload;
|
||||||
|
|
@ -82,12 +59,14 @@ function CustomTooltip({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border/50 bg-background px-3 py-2 text-xs shadow-xl">
|
<div className="rounded-lg border border-border/50 bg-background px-3 py-2 text-xs shadow-xl">
|
||||||
<div className="mb-2 font-medium">{formatMonth(data.month)}</div>
|
<div className="mb-2 font-medium">
|
||||||
|
{formatMonthFromYearMonth(data.month, locale)}
|
||||||
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="size-2 rounded-full bg-[var(--color-chart-2)]" />
|
<span className="size-2 rounded-full bg-[var(--color-chart-2)]" />
|
||||||
Income
|
{__('Income')}
|
||||||
</span>
|
</span>
|
||||||
<AmountDisplay
|
<AmountDisplay
|
||||||
amountInCents={data.income}
|
amountInCents={data.income}
|
||||||
|
|
@ -100,7 +79,7 @@ function CustomTooltip({
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="size-2 rounded-full bg-[var(--color-chart-5)]" />
|
<span className="size-2 rounded-full bg-[var(--color-chart-5)]" />
|
||||||
Expenses
|
{__('Expenses')}
|
||||||
</span>
|
</span>
|
||||||
<AmountDisplay
|
<AmountDisplay
|
||||||
amountInCents={data.expense}
|
amountInCents={data.expense}
|
||||||
|
|
@ -113,7 +92,7 @@ function CustomTooltip({
|
||||||
<div className="flex items-center justify-between gap-4 border-t pt-1">
|
<div className="flex items-center justify-between gap-4 border-t pt-1">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="size-2 rounded-full bg-[var(--color-chart-1)]" />
|
<span className="size-2 rounded-full bg-[var(--color-chart-1)]" />
|
||||||
Net
|
{__('Net')}
|
||||||
</span>
|
</span>
|
||||||
<AmountDisplay
|
<AmountDisplay
|
||||||
amountInCents={data.net}
|
amountInCents={data.net}
|
||||||
|
|
@ -137,9 +116,25 @@ export function CashflowTrendChart({
|
||||||
className,
|
className,
|
||||||
currency = 'USD',
|
currency = 'USD',
|
||||||
}: CashflowTrendChartProps) {
|
}: CashflowTrendChartProps) {
|
||||||
|
const locale = useLocale();
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const minChartWidth = data.length * 60;
|
const minChartWidth = data.length * 60;
|
||||||
|
|
||||||
|
const chartConfig: ChartConfig = {
|
||||||
|
income: {
|
||||||
|
label: __('Income'),
|
||||||
|
color: 'var(--color-chart-2)',
|
||||||
|
},
|
||||||
|
expense: {
|
||||||
|
label: __('Expenses'),
|
||||||
|
color: 'var(--color-chart-5)',
|
||||||
|
},
|
||||||
|
net: {
|
||||||
|
label: __('Net'),
|
||||||
|
color: 'var(--color-chart-1)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollContainerRef.current) {
|
if (scrollContainerRef.current) {
|
||||||
scrollContainerRef.current.scrollLeft =
|
scrollContainerRef.current.scrollLeft =
|
||||||
|
|
@ -151,7 +146,9 @@ export function CashflowTrendChart({
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Cashflow Trend</CardTitle>
|
<CardTitle className="text-base">
|
||||||
|
{__('Cashflow Trend')}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[250px] animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
<div className="h-[250px] animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||||
|
|
@ -163,10 +160,13 @@ export function CashflowTrendChart({
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardHeader className="gap-1 pb-4">
|
<CardHeader className="gap-1 pb-4">
|
||||||
<CardTitle className="text-base">Cashflow Trend</CardTitle>
|
<CardTitle className="text-base">
|
||||||
|
{__('Cashflow Trend')}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Monthly income, expenses, and net cashflow over the last 12
|
{__(
|
||||||
months
|
'Monthly income, expenses, and net cashflow over the last 12 months',
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -183,33 +183,36 @@ export function CashflowTrendChart({
|
||||||
stroke="var(--color-border)"
|
stroke="var(--color-border)"
|
||||||
opacity={0.3}
|
opacity={0.3}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="month"
|
dataKey="month"
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tickMargin={10}
|
tickMargin={10}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={formatMonth}
|
tickFormatter={(value) =>
|
||||||
|
formatMonthFromYearMonth(value, locale)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<YAxis
|
<YAxis
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(value: number) => {
|
tickFormatter={(value: number) =>
|
||||||
return new Intl.NumberFormat('en-US', {
|
formatCompactNumber(
|
||||||
notation: 'compact',
|
value / 100,
|
||||||
compactDisplay: 'short',
|
locale,
|
||||||
style: 'currency',
|
currency,
|
||||||
currency: currency,
|
)
|
||||||
minimumFractionDigits: 0,
|
}
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(value / 100);
|
|
||||||
}}
|
|
||||||
width={60}
|
width={60}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
y={0}
|
y={0}
|
||||||
stroke="var(--color-border)"
|
stroke="var(--color-border)"
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={<CustomTooltip currency={currency} />}
|
content={<CustomTooltip currency={currency} />}
|
||||||
cursor={{
|
cursor={{
|
||||||
|
|
@ -217,6 +220,7 @@ export function CashflowTrendChart({
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="income"
|
dataKey="income"
|
||||||
fill="var(--color-chart-2)"
|
fill="var(--color-chart-2)"
|
||||||
|
|
@ -224,6 +228,7 @@ export function CashflowTrendChart({
|
||||||
stackId="a"
|
stackId="a"
|
||||||
name="Income"
|
name="Income"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="expense"
|
dataKey="expense"
|
||||||
fill="var(--color-chart-5)"
|
fill="var(--color-chart-5)"
|
||||||
|
|
@ -231,6 +236,7 @@ export function CashflowTrendChart({
|
||||||
stackId="b"
|
stackId="b"
|
||||||
name="Expenses"
|
name="Expenses"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="net"
|
dataKey="net"
|
||||||
|
|
@ -250,15 +256,15 @@ export function CashflowTrendChart({
|
||||||
<div className="mt-4 flex items-center justify-center gap-6 text-xs">
|
<div className="mt-4 flex items-center justify-center gap-6 text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="size-3 rounded bg-[var(--color-chart-2)]" />
|
<span className="size-3 rounded bg-[var(--color-chart-2)]" />
|
||||||
<span>Income</span>
|
<span>{__('Income')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="size-3 rounded bg-[var(--color-chart-5)]" />
|
<span className="size-3 rounded bg-[var(--color-chart-5)]" />
|
||||||
<span>Expenses</span>
|
<span>{__('Expenses')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="size-3 rounded-full bg-[var(--color-chart-1)]" />
|
<span className="size-3 rounded-full bg-[var(--color-chart-1)]" />
|
||||||
<span>Net</span>
|
<span>{__('Net')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { AmountDisplay } from '@/components/ui/amount-display';
|
||||||
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
|
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
|
||||||
import { ChangeDataPoint } from '@/lib/chart-calculations';
|
import { ChangeDataPoint } from '@/lib/chart-calculations';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
|
|
@ -54,7 +55,7 @@ function CustomTooltip({ active, payload, currencyCode }: CustomTooltipProps) {
|
||||||
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
||||||
<div className="font-medium">{data.month}</div>
|
<div className="font-medium">{data.month}</div>
|
||||||
<div className="mt-1 flex items-center justify-between gap-4">
|
<div className="mt-1 flex items-center justify-between gap-4">
|
||||||
<span className="text-muted-foreground">Change</span>
|
<span className="text-muted-foreground">{__('Change')}</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono font-medium tabular-nums',
|
'font-mono font-medium tabular-nums',
|
||||||
|
|
@ -114,6 +115,7 @@ export function MoMChart({
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={xAxisFormatter}
|
tickFormatter={xAxisFormatter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<YAxis
|
<YAxis
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
|
|
@ -125,15 +127,18 @@ export function MoMChart({
|
||||||
}}
|
}}
|
||||||
width={50}
|
width={50}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
y={0}
|
y={0}
|
||||||
stroke="var(--color-border)"
|
stroke="var(--color-border)"
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={<CustomTooltip currencyCode={currencyCode} />}
|
content={<CustomTooltip currencyCode={currencyCode} />}
|
||||||
cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }}
|
cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Bar dataKey="displayValue" radius={[4, 4, 0, 0]}>
|
<Bar dataKey="displayValue" radius={[4, 4, 0, 0]}>
|
||||||
{chartData.map((entry, index) => {
|
{chartData.map((entry, index) => {
|
||||||
const value = entry.displayValue;
|
const value = entry.displayValue;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
|
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
|
||||||
import { formatPercentValue, PercentDataPoint } from '@/lib/chart-calculations';
|
import { formatPercentValue, PercentDataPoint } from '@/lib/chart-calculations';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
|
|
@ -51,7 +52,7 @@ function CustomTooltip({ active, payload }: CustomTooltipProps) {
|
||||||
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
||||||
<div className="font-medium">{data.month}</div>
|
<div className="font-medium">{data.month}</div>
|
||||||
<div className="mt-1 flex items-center justify-between gap-4">
|
<div className="mt-1 flex items-center justify-between gap-4">
|
||||||
<span className="text-muted-foreground">Change</span>
|
<span className="text-muted-foreground">{__('Change')}</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono font-medium tabular-nums',
|
'font-mono font-medium tabular-nums',
|
||||||
|
|
@ -105,6 +106,7 @@ export function MoMPercentChart({
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={xAxisFormatter}
|
tickFormatter={xAxisFormatter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<YAxis
|
<YAxis
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
|
|
@ -113,15 +115,18 @@ export function MoMPercentChart({
|
||||||
}
|
}
|
||||||
width={50}
|
width={50}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
y={0}
|
y={0}
|
||||||
stroke="var(--color-border)"
|
stroke="var(--color-border)"
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={<CustomTooltip />}
|
content={<CustomTooltip />}
|
||||||
cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }}
|
cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Bar dataKey="displayValue" radius={[4, 4, 0, 0]}>
|
<Bar dataKey="displayValue" radius={[4, 4, 0, 0]}>
|
||||||
{chartData.map((entry, index) => {
|
{chartData.map((entry, index) => {
|
||||||
const value = entry.displayValue;
|
const value = entry.displayValue;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from '@/lib/sankey-utils';
|
} from '@/lib/sankey-utils';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Category } from '@/types/category';
|
import { Category } from '@/types/category';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
interface SankeyChartProps {
|
interface SankeyChartProps {
|
||||||
|
|
@ -75,10 +76,11 @@ function OtherCategoriesBreakdown({
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium">
|
<h4 className="text-sm font-medium">
|
||||||
Other Categories ({categories.length})
|
{__('Other Categories (')}
|
||||||
|
{categories.length})
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Categories below 5% of total
|
{__('Categories below 5% of total')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -102,6 +104,7 @@ function OtherCategoriesBreakdown({
|
||||||
'var(--color-chart-4)',
|
'var(--color-chart-4)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{item.category.name}
|
{item.category.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -122,7 +125,7 @@ function OtherCategoriesBreakdown({
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-sm font-medium">
|
<div className="flex items-center justify-between text-sm font-medium">
|
||||||
<span>Total</span>
|
<span>{__('Total')}</span>
|
||||||
<span>{formatAmount(total, currency)}</span>
|
<span>{formatAmount(total, currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -201,7 +204,7 @@ export function SankeyChart({
|
||||||
);
|
);
|
||||||
const otherNode: NodeData = {
|
const otherNode: NodeData = {
|
||||||
id: 'income-other',
|
id: 'income-other',
|
||||||
label: 'Other',
|
label: __('Other'),
|
||||||
value: groupedIncome.other.total,
|
value: groupedIncome.other.total,
|
||||||
color: 'var(--color-muted)',
|
color: 'var(--color-muted)',
|
||||||
y: incomeY,
|
y: incomeY,
|
||||||
|
|
@ -223,7 +226,7 @@ export function SankeyChart({
|
||||||
const centerY = (height - centerHeight) / 2;
|
const centerY = (height - centerHeight) / 2;
|
||||||
const centerNode: NodeData = {
|
const centerNode: NodeData = {
|
||||||
id: 'center',
|
id: 'center',
|
||||||
label: 'Cashflow',
|
label: __('Cashflow'),
|
||||||
value: total_income - total_expense,
|
value: total_income - total_expense,
|
||||||
color: 'var(--color-chart-1)',
|
color: 'var(--color-chart-1)',
|
||||||
y: centerY,
|
y: centerY,
|
||||||
|
|
@ -267,7 +270,7 @@ export function SankeyChart({
|
||||||
);
|
);
|
||||||
const otherNode: NodeData = {
|
const otherNode: NodeData = {
|
||||||
id: 'expense-other',
|
id: 'expense-other',
|
||||||
label: 'Other',
|
label: __('Other'),
|
||||||
value: groupedExpense.other.total,
|
value: groupedExpense.other.total,
|
||||||
color: 'var(--color-muted)',
|
color: 'var(--color-muted)',
|
||||||
y: expenseY,
|
y: expenseY,
|
||||||
|
|
@ -334,7 +337,7 @@ export function SankeyChart({
|
||||||
)}
|
)}
|
||||||
style={{ height }}
|
style={{ height }}
|
||||||
>
|
>
|
||||||
No cashflow data for this period
|
{__('No cashflow data for this period')}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -453,6 +456,7 @@ export function SankeyChart({
|
||||||
strokeWidth={isOtherNode ? 1 : 0}
|
strokeWidth={isOtherNode ? 1 : 0}
|
||||||
className="transition-all duration-200"
|
className="transition-all duration-200"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
<text
|
<text
|
||||||
x={
|
x={
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { EncryptedText } from '@/components/encrypted-text';
|
||||||
import { AmountDisplay } from '@/components/ui/amount-display';
|
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
|
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts';
|
import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||||
|
|
@ -27,7 +28,7 @@ export function AccountBalanceCard({
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle className="text-sm font-medium">
|
||||||
Loading...
|
{__('Loading...')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -87,7 +88,7 @@ export function AccountBalanceCard({
|
||||||
<AmountTrendIndicator
|
<AmountTrendIndicator
|
||||||
isPositive={isPositive}
|
isPositive={isPositive}
|
||||||
trend={Math.abs(account.diff)}
|
trend={Math.abs(account.diff)}
|
||||||
label="vs last month"
|
label={__('vs last month')}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
previousAmount={account.previousBalance}
|
previousAmount={account.previousBalance}
|
||||||
currentAmount={account.currentBalance}
|
currentAmount={account.currentBalance}
|
||||||
|
|
@ -125,6 +126,7 @@ export function AccountBalanceCard({
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { cashflow } from '@/routes';
|
import { cashflow } from '@/routes';
|
||||||
import { SharedData } from '@/types';
|
import { SharedData } from '@/types';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Link, usePage } from '@inertiajs/react';
|
import { Link, usePage } from '@inertiajs/react';
|
||||||
import { endOfMonth, format, startOfMonth } from 'date-fns';
|
import { endOfMonth, format, startOfMonth } from 'date-fns';
|
||||||
import { ArrowRight, TrendingDown, TrendingUp } from 'lucide-react';
|
import { ArrowRight, TrendingDown, TrendingUp } from 'lucide-react';
|
||||||
|
|
@ -61,7 +62,7 @@ export function CashflowSummaryCard({ loading }: CashflowSummaryCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="col-span-3">
|
<Card className="col-span-3">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Cashflow</CardTitle>
|
<CardTitle>{__('Cashflow')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
|
@ -88,24 +89,27 @@ export function CashflowSummaryCard({ loading }: CashflowSummaryCardProps) {
|
||||||
<Card className="col-span-3">
|
<Card className="col-span-3">
|
||||||
<CardHeader className="gap-1">
|
<CardHeader className="gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle>Cashflow</CardTitle>
|
<CardTitle>{__('Cashflow')}</CardTitle>
|
||||||
<Link
|
<Link
|
||||||
href={cashflow().url}
|
href={cashflow().url}
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
View details
|
{__('View details')}
|
||||||
|
|
||||||
<ArrowRight className="size-4" />
|
<ArrowRight className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
This month's income and expenses
|
{__("This month's income and expenses")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{/* Income */}
|
{/* Income */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Income</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{__('Income')}
|
||||||
|
</p>
|
||||||
<AmountDisplay
|
<AmountDisplay
|
||||||
amountInCents={current.income}
|
amountInCents={current.income}
|
||||||
currencyCode={auth.user.currency_code}
|
currencyCode={auth.user.currency_code}
|
||||||
|
|
@ -119,7 +123,7 @@ export function CashflowSummaryCard({ loading }: CashflowSummaryCardProps) {
|
||||||
{/* Expenses */}
|
{/* Expenses */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Expenses
|
{__('Expenses')}
|
||||||
</p>
|
</p>
|
||||||
<AmountDisplay
|
<AmountDisplay
|
||||||
amountInCents={current.expense}
|
amountInCents={current.expense}
|
||||||
|
|
@ -132,7 +136,9 @@ export function CashflowSummaryCard({ loading }: CashflowSummaryCardProps) {
|
||||||
|
|
||||||
{/* Net */}
|
{/* Net */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Net</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{__('Net')}
|
||||||
|
</p>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{isPositiveNet ? (
|
{isPositiveNet ? (
|
||||||
<TrendingUp className="size-4 text-green-600 dark:text-green-400" />
|
<TrendingUp className="size-4 text-green-600 dark:text-green-400" />
|
||||||
|
|
@ -154,7 +160,7 @@ export function CashflowSummaryCard({ loading }: CashflowSummaryCardProps) {
|
||||||
{/* Savings rate footer */}
|
{/* Savings rate footer */}
|
||||||
<div className="mt-4 flex items-center justify-between border-t pt-3">
|
<div className="mt-4 flex items-center justify-between border-t pt-3">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Savings rate
|
{__('Savings rate')}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ import { ChartConfig } from '@/components/ui/chart';
|
||||||
import { StackedBarChart } from '@/components/ui/stacked-bar-chart';
|
import { StackedBarChart } from '@/components/ui/stacked-bar-chart';
|
||||||
import { useChartViews } from '@/hooks/use-chart-views';
|
import { useChartViews } from '@/hooks/use-chart-views';
|
||||||
import { NetWorthEvolutionData } from '@/hooks/use-dashboard-data';
|
import { NetWorthEvolutionData } from '@/hooks/use-dashboard-data';
|
||||||
|
import { useLocale } from '@/hooks/use-locale';
|
||||||
import { AccountInfo } from '@/lib/chart-calculations';
|
import { AccountInfo } from '@/lib/chart-calculations';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { PercentageTrendIndicator } from './percentage-trend-indicator';
|
import { PercentageTrendIndicator } from './percentage-trend-indicator';
|
||||||
|
|
||||||
|
|
@ -32,10 +34,10 @@ interface TrendData {
|
||||||
currentAmount: number;
|
currentAmount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatXAxisLabel(value: string): string {
|
function formatXAxisLabel(value: string, locale: string = 'en'): string {
|
||||||
const [year, month] = value.split('-');
|
const [year, month] = value.split('-');
|
||||||
const date = new Date(parseInt(year), parseInt(month) - 1);
|
const date = new Date(parseInt(year), parseInt(month) - 1);
|
||||||
const monthName = date.toLocaleString('en-US', { month: 'short' });
|
const monthName = date.toLocaleString(locale, { month: 'short' });
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
if (parseInt(year) === currentYear) {
|
if (parseInt(year) === currentYear) {
|
||||||
|
|
@ -138,6 +140,7 @@ export function NetWorthChart({
|
||||||
loading,
|
loading,
|
||||||
showLegend = false,
|
showLegend = false,
|
||||||
}: NetWorthChartProps) {
|
}: NetWorthChartProps) {
|
||||||
|
const locale = useLocale();
|
||||||
const {
|
const {
|
||||||
chartData,
|
chartData,
|
||||||
dataKeys,
|
dataKeys,
|
||||||
|
|
@ -240,7 +243,7 @@ export function NetWorthChart({
|
||||||
return (
|
return (
|
||||||
<Card className="col-span-3">
|
<Card className="col-span-3">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Net Worth Evolution</CardTitle>
|
<CardTitle>{__('Net Worth Evolution')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
<div className="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
<div className="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|
@ -256,11 +259,11 @@ export function NetWorthChart({
|
||||||
return (
|
return (
|
||||||
<Card className="col-span-3">
|
<Card className="col-span-3">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Net Worth Evolution</CardTitle>
|
<CardTitle>{__('Net Worth Evolution')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
||||||
No account data available
|
{__('No account data available')}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -272,21 +275,22 @@ export function NetWorthChart({
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-row items-start justify-between gap-4">
|
<div className="flex flex-row items-start justify-between gap-4">
|
||||||
<div className="flex min-w-0 flex-col gap-2">
|
<div className="flex min-w-0 flex-col gap-2">
|
||||||
<CardTitle>Net Worth Evolution</CardTitle>
|
<CardTitle>{__('Net Worth Evolution')}</CardTitle>
|
||||||
<CardDescription className="flex flex-col gap-1 text-sm">
|
<CardDescription className="flex flex-col gap-1 text-sm">
|
||||||
<div className="text-foreground">
|
<div className="text-foreground">
|
||||||
<TotalDisplay totals={currencyTotals} />
|
<TotalDisplay totals={currencyTotals} />
|
||||||
</div>
|
</div>
|
||||||
<PercentageTrendIndicator
|
<PercentageTrendIndicator
|
||||||
trend={monthlyTrend?.percentage ?? null}
|
trend={monthlyTrend?.percentage ?? null}
|
||||||
label="this month"
|
label={__('this month')}
|
||||||
previousAmount={monthlyTrend?.previousAmount}
|
previousAmount={monthlyTrend?.previousAmount}
|
||||||
currentAmount={monthlyTrend?.currentAmount}
|
currentAmount={monthlyTrend?.currentAmount}
|
||||||
currencyCode={primaryCurrency}
|
currencyCode={primaryCurrency}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PercentageTrendIndicator
|
<PercentageTrendIndicator
|
||||||
trend={yearlyTrend?.percentage ?? null}
|
trend={yearlyTrend?.percentage ?? null}
|
||||||
label="for the last 12 months"
|
label={__('for the last 12 months')}
|
||||||
previousAmount={yearlyTrend?.previousAmount}
|
previousAmount={yearlyTrend?.previousAmount}
|
||||||
currentAmount={yearlyTrend?.currentAmount}
|
currentAmount={yearlyTrend?.currentAmount}
|
||||||
currencyCode={primaryCurrency}
|
currencyCode={primaryCurrency}
|
||||||
|
|
@ -308,7 +312,9 @@ export function NetWorthChart({
|
||||||
dataKeys={dataKeys}
|
dataKeys={dataKeys}
|
||||||
config={chartConfig}
|
config={chartConfig}
|
||||||
xAxisKey="month"
|
xAxisKey="month"
|
||||||
xAxisFormatter={formatXAxisLabel}
|
xAxisFormatter={(value) =>
|
||||||
|
formatXAxisLabel(value, locale)
|
||||||
|
}
|
||||||
valueFormatter={valueFormatter}
|
valueFormatter={valueFormatter}
|
||||||
accountCurrencies={accountCurrencies}
|
accountCurrencies={accountCurrencies}
|
||||||
className="h-[300px] w-full"
|
className="h-[300px] w-full"
|
||||||
|
|
@ -319,14 +325,18 @@ export function NetWorthChart({
|
||||||
<MoMChart
|
<MoMChart
|
||||||
data={chartViews.deltaSeries}
|
data={chartViews.deltaSeries}
|
||||||
currencyCode={primaryCurrency}
|
currencyCode={primaryCurrency}
|
||||||
xAxisFormatter={formatXAxisLabel}
|
xAxisFormatter={(value) =>
|
||||||
|
formatXAxisLabel(value, locale)
|
||||||
|
}
|
||||||
className="h-[300px] w-full"
|
className="h-[300px] w-full"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{chartViews.currentView === 'mom_percent' && (
|
{chartViews.currentView === 'mom_percent' && (
|
||||||
<MoMPercentChart
|
<MoMPercentChart
|
||||||
data={chartViews.momPercentSeries}
|
data={chartViews.momPercentSeries}
|
||||||
xAxisFormatter={formatXAxisLabel}
|
xAxisFormatter={(value) =>
|
||||||
|
formatXAxisLabel(value, locale)
|
||||||
|
}
|
||||||
className="h-[300px] w-full"
|
className="h-[300px] w-full"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { Progress } from '@/components/ui/progress';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SharedData } from '@/types';
|
import { SharedData } from '@/types';
|
||||||
import { Category, getCategoryColorClasses } from '@/types/category';
|
import { Category, getCategoryColorClasses } from '@/types/category';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { usePage } from '@inertiajs/react';
|
import { usePage } from '@inertiajs/react';
|
||||||
import * as Icons from 'lucide-react';
|
import * as Icons from 'lucide-react';
|
||||||
import { LucideIcon } from 'lucide-react';
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
|
@ -50,7 +51,7 @@ export function TopCategoriesCard({
|
||||||
return (
|
return (
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Top Spending Categories</CardTitle>
|
<CardTitle>{__('Top Spending Categories')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -73,8 +74,8 @@ export function TopCategoriesCard({
|
||||||
return (
|
return (
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader className="gap-2">
|
<CardHeader className="gap-2">
|
||||||
<CardTitle>Top spending categories</CardTitle>
|
<CardTitle>{__('Top spending categories')}</CardTitle>
|
||||||
<CardDescription>on the last 30 days</CardDescription>
|
<CardDescription>{__('on the last 30 days')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -147,7 +148,7 @@ export function TopCategoriesCard({
|
||||||
})}
|
})}
|
||||||
{categories.length === 0 && (
|
{categories.length === 0 && (
|
||||||
<div className="py-8 text-center text-muted-foreground">
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
No spending data this month
|
{__('No spending data this month')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { type SharedData } from '@/types';
|
import { type SharedData } from '@/types';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Form, usePage } from '@inertiajs/react';
|
import { Form, usePage } from '@inertiajs/react';
|
||||||
import { InfoIcon } from 'lucide-react';
|
import { InfoIcon } from 'lucide-react';
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
|
|
@ -28,13 +29,16 @@ export default function DeleteUser() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<HeadingSmall
|
<HeadingSmall
|
||||||
title="Delete account"
|
title={__('Delete account')}
|
||||||
description="Delete your account and all of its resources"
|
description={__(
|
||||||
|
'Delete your account and all of its resources',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Alert>
|
<Alert>
|
||||||
<InfoIcon className="h-4 w-4" />
|
<InfoIcon className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
The demo account cannot be deleted.
|
{__('The demo account cannot be deleted.')}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -44,14 +48,17 @@ export default function DeleteUser() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<HeadingSmall
|
<HeadingSmall
|
||||||
title="Delete account"
|
title={__('Delete account')}
|
||||||
description="Delete your account and all of its resources"
|
description={__('Delete your account and all of its resources')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10">
|
<div className="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10">
|
||||||
<div className="relative space-y-0.5 text-red-600 dark:text-red-100">
|
<div className="relative space-y-0.5 text-red-600 dark:text-red-100">
|
||||||
<p className="font-medium">Warning</p>
|
<p className="font-medium">{__('Warning')}</p>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Please proceed with caution, this cannot be undone.
|
{__(
|
||||||
|
'Please proceed with caution, this cannot be undone.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -61,18 +68,19 @@ export default function DeleteUser() {
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
data-test="delete-user-button"
|
data-test="delete-user-button"
|
||||||
>
|
>
|
||||||
Delete account
|
{__('Delete account')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Are you sure you want to delete your account?
|
{__(
|
||||||
|
'Are you sure you want to delete your account?',
|
||||||
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Once your account is deleted, all of its resources
|
{__(
|
||||||
and data will also be permanently deleted. Please
|
'Once your account is deleted, all of its resources\n and data will also be permanently deleted. Please\n enter your password to confirm you would like to\n permanently delete your account.',
|
||||||
enter your password to confirm you would like to
|
)}
|
||||||
permanently delete your account.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
|
|
@ -91,7 +99,7 @@ export default function DeleteUser() {
|
||||||
htmlFor="password"
|
htmlFor="password"
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
>
|
>
|
||||||
Password
|
{__('Password')}
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -99,7 +107,7 @@ export default function DeleteUser() {
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
ref={passwordInput}
|
ref={passwordInput}
|
||||||
placeholder="Password"
|
placeholder={__('Password')}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -114,7 +122,7 @@ export default function DeleteUser() {
|
||||||
resetAndClearErrors()
|
resetAndClearErrors()
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
|
|
||||||
|
|
@ -127,7 +135,7 @@ export default function DeleteUser() {
|
||||||
type="submit"
|
type="submit"
|
||||||
data-test="confirm-delete-user-button"
|
data-test="confirm-delete-user-button"
|
||||||
>
|
>
|
||||||
Delete account
|
{__('Delete account')}
|
||||||
</button>
|
</button>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { LockKeyhole, LockKeyholeOpen } from 'lucide-react';
|
import { LockKeyhole, LockKeyholeOpen } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
|
@ -70,8 +71,8 @@ export function EncryptionKeyButton() {
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
aria-label={
|
aria-label={
|
||||||
isKeySet
|
isKeySet
|
||||||
? 'Lock encryption key'
|
? __('Lock encryption key')
|
||||||
: 'Unlock encryption key'
|
: __('Unlock encryption key')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isKeySet ? (
|
{isKeySet ? (
|
||||||
|
|
@ -83,8 +84,8 @@ export function EncryptionKeyButton() {
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{isKeySet
|
{isKeySet
|
||||||
? 'Click to lock encryption key'
|
? __('Click to lock encryption key')
|
||||||
: 'Click to unlock encryption key'}
|
: __('Click to unlock encryption key')}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
@ -103,11 +104,11 @@ export function EncryptionKeyButton() {
|
||||||
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
|
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Clear Encryption Key?</DialogTitle>
|
<DialogTitle>{__('Clear Encryption Key?')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This will remove your encryption key from this
|
{__(
|
||||||
browser session. You'll need to enter your password
|
"This will remove your encryption key from this browser session. You'll need to enter your password again to unlock encrypted content.",
|
||||||
again to unlock encrypted content.
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
@ -115,9 +116,11 @@ export function EncryptionKeyButton() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowClearDialog(false)}
|
onClick={() => setShowClearDialog(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleClearKey}>
|
||||||
|
{__('Clear Key')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleClearKey}>Clear Key</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { getLabelColorClasses, LABEL_COLORS } from '@/types/label';
|
import { getLabelColorClasses, LABEL_COLORS } from '@/types/label';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Form } from '@inertiajs/react';
|
import { Form } from '@inertiajs/react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -28,13 +29,13 @@ export function CreateLabelDialog({ onSuccess }: { onSuccess?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button>Create Label</Button>
|
<Button>{__('Create Label')}</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create Label</DialogTitle>
|
<DialogTitle>{__('Create Label')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Add a new label to tag your transactions.
|
{__('Add a new label to tag your transactions.')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form
|
<Form
|
||||||
|
|
@ -48,13 +49,14 @@ export function CreateLabelDialog({ onSuccess }: { onSuccess?: () => void }) {
|
||||||
{({ errors, processing }) => (
|
{({ errors, processing }) => (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor="name">{__('Name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Label name"
|
placeholder={__('Label name')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<p className="text-sm text-red-500">
|
<p className="text-sm text-red-500">
|
||||||
{errors.name}
|
{errors.name}
|
||||||
|
|
@ -63,10 +65,12 @@ export function CreateLabelDialog({ onSuccess }: { onSuccess?: () => void }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="color">Color</Label>
|
<Label htmlFor="color">{__('Color')}</Label>
|
||||||
<Select name="color" required>
|
<Select name="color" required>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a color" />
|
<SelectValue
|
||||||
|
placeholder={__('Select a color')}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{LABEL_COLORS.map((color) => {
|
{LABEL_COLORS.map((color) => {
|
||||||
|
|
@ -81,7 +85,7 @@ export function CreateLabelDialog({ onSuccess }: { onSuccess?: () => void }) {
|
||||||
<Badge
|
<Badge
|
||||||
className={`${colorClasses.bg} ${colorClasses.text}`}
|
className={`${colorClasses.bg} ${colorClasses.text}`}
|
||||||
>
|
>
|
||||||
{color}
|
{__(color)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -103,7 +107,7 @@ export function CreateLabelDialog({ onSuccess }: { onSuccess?: () => void }) {
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={processing}>
|
<Button type="submit" disabled={processing}>
|
||||||
{processing ? 'Creating...' : 'Create'}
|
{processing ? 'Creating...' : 'Create'}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { type Label } from '@/types/label';
|
import { type Label } from '@/types/label';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Form } from '@inertiajs/react';
|
import { Form } from '@inertiajs/react';
|
||||||
|
|
||||||
interface DeleteLabelDialogProps {
|
interface DeleteLabelDialogProps {
|
||||||
|
|
@ -28,10 +29,13 @@ export function DeleteLabelDialog({
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Label</DialogTitle>
|
<DialogTitle>{__('Delete Label')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete "{label.name}"? This
|
{__('Are you sure you want to delete "')}
|
||||||
action cannot be undone.
|
{label.name}
|
||||||
|
{__(
|
||||||
|
'"? This\n action cannot be undone.',
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form
|
<Form
|
||||||
|
|
@ -49,7 +53,7 @@ export function DeleteLabelDialog({
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
LABEL_COLORS,
|
LABEL_COLORS,
|
||||||
type Label as LabelType,
|
type Label as LabelType,
|
||||||
} from '@/types/label';
|
} from '@/types/label';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Form } from '@inertiajs/react';
|
import { Form } from '@inertiajs/react';
|
||||||
|
|
||||||
interface EditLabelDialogProps {
|
interface EditLabelDialogProps {
|
||||||
|
|
@ -41,9 +42,9 @@ export function EditLabelDialog({
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Label</DialogTitle>
|
<DialogTitle>{__('Edit Label')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update the label information.
|
{__('Update the label information.')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form
|
<Form
|
||||||
|
|
@ -57,14 +58,15 @@ export function EditLabelDialog({
|
||||||
{({ errors, processing }) => (
|
{({ errors, processing }) => (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor="name">{__('Name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
defaultValue={label.name}
|
defaultValue={label.name}
|
||||||
placeholder="Label name"
|
placeholder={__('Label name')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<p className="text-sm text-red-500">
|
<p className="text-sm text-red-500">
|
||||||
{errors.name}
|
{errors.name}
|
||||||
|
|
@ -73,14 +75,16 @@ export function EditLabelDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="color">Color</Label>
|
<Label htmlFor="color">{__('Color')}</Label>
|
||||||
<Select
|
<Select
|
||||||
name="color"
|
name="color"
|
||||||
defaultValue={label.color}
|
defaultValue={label.color}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a color" />
|
<SelectValue
|
||||||
|
placeholder={__('Select a color')}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{LABEL_COLORS.map((color) => {
|
{LABEL_COLORS.map((color) => {
|
||||||
|
|
@ -95,7 +99,7 @@ export function EditLabelDialog({
|
||||||
<Badge
|
<Badge
|
||||||
className={`${colorClasses.bg} ${colorClasses.text}`}
|
className={`${colorClasses.bg} ${colorClasses.text}`}
|
||||||
>
|
>
|
||||||
{color}
|
{__(color)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -117,7 +121,7 @@ export function EditLabelDialog({
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
>
|
>
|
||||||
Cancel
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={processing}>
|
<Button type="submit" disabled={processing}>
|
||||||
{processing ? 'Updating...' : 'Update'}
|
{processing ? 'Updating...' : 'Update'}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { usePwaInstall } from '@/hooks/use-pwa-install';
|
import { usePwaInstall } from '@/hooks/use-pwa-install';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { DownloadIcon, PlusSquareIcon, ShareIcon } from 'lucide-react';
|
import { DownloadIcon, PlusSquareIcon, ShareIcon } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -26,7 +27,7 @@ export default function InstallAppButton() {
|
||||||
className="text-shadow h-14 w-full cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:bg-[#eeeeec] dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:bg-white hover:dark:from-zinc-50 dark:hover:shadow-md"
|
className="text-shadow h-14 w-full cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:bg-[#eeeeec] dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:bg-white hover:dark:from-zinc-50 dark:hover:shadow-md"
|
||||||
>
|
>
|
||||||
<DownloadIcon className="size-5" />
|
<DownloadIcon className="size-5" />
|
||||||
Install App
|
{__('Install App')}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -39,16 +40,19 @@ export default function InstallAppButton() {
|
||||||
className="text-shadow h-14 w-full cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:bg-[#eeeeec] dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:bg-white hover:dark:from-zinc-50 dark:hover:shadow-md"
|
className="text-shadow h-14 w-full cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:bg-[#eeeeec] dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:bg-white hover:dark:from-zinc-50 dark:hover:shadow-md"
|
||||||
>
|
>
|
||||||
<DownloadIcon className="size-5" />
|
<DownloadIcon className="size-5" />
|
||||||
Install App
|
{__('Install App')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog open={showIosDialog} onOpenChange={setShowIosDialog}>
|
<Dialog open={showIosDialog} onOpenChange={setShowIosDialog}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Install Whisper Money</DialogTitle>
|
<DialogTitle>
|
||||||
|
{__('Install Whisper Money')}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Add the app to your home screen for the best
|
{__(
|
||||||
experience.
|
'Add the app to your home screen for the best experience.',
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -58,11 +62,11 @@ export default function InstallAppButton() {
|
||||||
<ShareIcon className="size-5 text-zinc-600 dark:text-zinc-400" />
|
<ShareIcon className="size-5 text-zinc-600 dark:text-zinc-400" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
Tap the{' '}
|
{__('Tap the')}{' '}
|
||||||
<strong className="font-semibold">
|
<strong className="font-semibold">
|
||||||
Share
|
{__('Share')}
|
||||||
</strong>{' '}
|
</strong>{' '}
|
||||||
button in your browser toolbar
|
{__('button in your browser toolbar')}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-4">
|
<li className="flex items-center gap-4">
|
||||||
|
|
@ -70,9 +74,9 @@ export default function InstallAppButton() {
|
||||||
<PlusSquareIcon className="size-5 text-zinc-600 dark:text-zinc-400" />
|
<PlusSquareIcon className="size-5 text-zinc-600 dark:text-zinc-400" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
Tap{' '}
|
{__('Tap')}{' '}
|
||||||
<strong className="font-semibold">
|
<strong className="font-semibold">
|
||||||
Add to Home Screen
|
{__('Add to Home Screen')}
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -83,7 +87,7 @@ export default function InstallAppButton() {
|
||||||
className="mt-2 w-full"
|
className="mt-2 w-full"
|
||||||
onClick={() => setShowIosDialog(false)}
|
onClick={() => setShowIosDialog(false)}
|
||||||
>
|
>
|
||||||
Got it
|
{__('Got it')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import { resolveUrl } from '@/lib/utils';
|
import { resolveUrl } from '@/lib/utils';
|
||||||
import { type NavItem } from '@/types';
|
import { type NavItem } from '@/types';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { LucideIcon } from 'lucide-react';
|
import { LucideIcon } from 'lucide-react';
|
||||||
import { isValidElement, type ComponentPropsWithoutRef } from 'react';
|
import { isValidElement, type ComponentPropsWithoutRef } from 'react';
|
||||||
|
|
||||||
|
|
@ -37,7 +38,7 @@ export function NavFooter({
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<IconOrComponent icon={item.icon} />
|
<IconOrComponent icon={item.icon} />
|
||||||
<span>{item.title}</span>
|
<span>{__(item.title)}</span>
|
||||||
</a>
|
</a>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,14 @@ import {
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import { resolveUrl } from '@/lib/utils';
|
import { resolveUrl } from '@/lib/utils';
|
||||||
import { type NavItem } from '@/types';
|
import { type NavItem } from '@/types';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Link, usePage } from '@inertiajs/react';
|
import { Link, usePage } from '@inertiajs/react';
|
||||||
|
|
||||||
export function NavMain({ items = [] }: { items: NavItem[] }) {
|
export function NavMain({ items = [] }: { items: NavItem[] }) {
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className="px-2 py-0">
|
<SidebarGroup className="px-2 py-0">
|
||||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
<SidebarGroupLabel>{__('Platform')}</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
|
|
@ -22,11 +23,11 @@ export function NavMain({ items = [] }: { items: NavItem[] }) {
|
||||||
isActive={page.url.startsWith(
|
isActive={page.url.startsWith(
|
||||||
resolveUrl(item.href),
|
resolveUrl(item.href),
|
||||||
)}
|
)}
|
||||||
tooltip={{ children: item.title }}
|
tooltip={{ children: __(item.title) }}
|
||||||
>
|
>
|
||||||
<Link href={item.href} prefetch>
|
<Link href={item.href} prefetch>
|
||||||
{item.icon && <item.icon />}
|
{item.icon && <item.icon />}
|
||||||
<span>{item.title}</span>
|
<span>{__(item.title)}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { StepButton } from '@/components/onboarding/step-button';
|
import { StepButton } from '@/components/onboarding/step-button';
|
||||||
import { StepHeader } from '@/components/onboarding/step-header';
|
import { StepHeader } from '@/components/onboarding/step-header';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import {
|
import {
|
||||||
Banknote,
|
Banknote,
|
||||||
Building2,
|
Building2,
|
||||||
|
|
@ -17,44 +18,44 @@ interface StepAccountTypesProps {
|
||||||
const accountTypes = [
|
const accountTypes = [
|
||||||
{
|
{
|
||||||
type: 'checking',
|
type: 'checking',
|
||||||
name: 'Checking',
|
nameKey: 'Checking',
|
||||||
icon: Wallet,
|
icon: Wallet,
|
||||||
description: 'Daily spending and transactions',
|
descriptionKey: 'Daily spending and transactions',
|
||||||
hasTransactions: true,
|
hasTransactions: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'savings',
|
type: 'savings',
|
||||||
name: 'Savings',
|
nameKey: 'Savings',
|
||||||
icon: PiggyBank,
|
icon: PiggyBank,
|
||||||
description: 'Save money for goals',
|
descriptionKey: 'Save money for goals',
|
||||||
hasTransactions: true,
|
hasTransactions: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'credit_card',
|
type: 'credit_card',
|
||||||
name: 'Credit Card',
|
nameKey: 'Credit Card',
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
description: 'Track credit card spending',
|
descriptionKey: 'Track credit card spending',
|
||||||
hasTransactions: true,
|
hasTransactions: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'investment',
|
type: 'investment',
|
||||||
name: 'Investment',
|
nameKey: 'Investment',
|
||||||
icon: LineChart,
|
icon: LineChart,
|
||||||
description: 'Stocks, ETFs, and portfolios',
|
descriptionKey: 'Stocks, ETFs, and portfolios',
|
||||||
hasTransactions: false,
|
hasTransactions: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'retirement',
|
type: 'retirement',
|
||||||
name: 'Retirement',
|
nameKey: 'Retirement',
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
description: '401k, IRA, pension funds',
|
descriptionKey: '401k, IRA, pension funds',
|
||||||
hasTransactions: false,
|
hasTransactions: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'loan',
|
type: 'loan',
|
||||||
name: 'Loan',
|
nameKey: 'Loan',
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
description: 'Mortgages and loans',
|
descriptionKey: 'Mortgages and loans',
|
||||||
hasTransactions: false,
|
hasTransactions: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -65,8 +66,10 @@ export function StepAccountTypes({ onContinue }: StepAccountTypesProps) {
|
||||||
<StepHeader
|
<StepHeader
|
||||||
icon={Banknote}
|
icon={Banknote}
|
||||||
iconContainerClassName="bg-gradient-to-br from-cyan-400 to-blue-500"
|
iconContainerClassName="bg-gradient-to-br from-cyan-400 to-blue-500"
|
||||||
title="Account Types"
|
title={__('Account Types')}
|
||||||
description="There are different account types. Some track transactions, others just track balance over time."
|
description={__(
|
||||||
|
'There are different account types. Some track transactions, others just track balance over time.',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid w-full max-w-2xl gap-3 sm:grid-cols-2">
|
<div className="grid w-full max-w-2xl gap-3 sm:grid-cols-2">
|
||||||
|
|
@ -81,8 +84,9 @@ export function StepAccountTypes({ onContinue }: StepAccountTypesProps) {
|
||||||
<account.icon
|
<account.icon
|
||||||
className={`size-4 stroke-muted-foreground`}
|
className={`size-4 stroke-muted-foreground`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h3 className="font-semibold">
|
<h3 className="font-semibold">
|
||||||
{account.name}
|
{__(account.nameKey)}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -94,12 +98,12 @@ export function StepAccountTypes({ onContinue }: StepAccountTypesProps) {
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{account.hasTransactions
|
{account.hasTransactions
|
||||||
? 'Transactions + Balance'
|
? __('Transactions + Balance')
|
||||||
: 'Balance'}
|
: __('Balance')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{account.description}
|
{__(account.descriptionKey)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -108,7 +112,7 @@ export function StepAccountTypes({ onContinue }: StepAccountTypesProps) {
|
||||||
|
|
||||||
<div className="mt-8 w-full sm:w-auto">
|
<div className="mt-8 w-full sm:w-auto">
|
||||||
<StepButton
|
<StepButton
|
||||||
text="Create Your First Account"
|
text={__('Create Your First Account')}
|
||||||
onClick={onContinue}
|
onClick={onContinue}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { StepButton } from '@/components/onboarding/step-button';
|
import { StepButton } from '@/components/onboarding/step-button';
|
||||||
import { StepHeader } from '@/components/onboarding/step-header';
|
import { StepHeader } from '@/components/onboarding/step-header';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { ArrowDownLeft, ArrowUpRight, Repeat, Tag } from 'lucide-react';
|
import { ArrowDownLeft, ArrowUpRight, Repeat, Tag } from 'lucide-react';
|
||||||
|
|
||||||
interface StepCategoryTypesProps {
|
interface StepCategoryTypesProps {
|
||||||
|
|
@ -9,33 +10,30 @@ interface StepCategoryTypesProps {
|
||||||
const categoryTypes = [
|
const categoryTypes = [
|
||||||
{
|
{
|
||||||
type: 'expense',
|
type: 'expense',
|
||||||
name: 'Expense',
|
nameKey: 'Expense',
|
||||||
icon: ArrowUpRight,
|
icon: ArrowUpRight,
|
||||||
description:
|
descriptionKey:
|
||||||
'Money going out of an account to pay for something (e.g., groceries, rent, subscriptions). Decreases your balance.',
|
'Money going out of an account to pay for something (e.g., groceries, rent, subscriptions). Decreases your balance.',
|
||||||
examples: ['Food', 'Rent', 'Entertainment', 'Transport'],
|
|
||||||
color: 'from-red-500 to-rose-500',
|
color: 'from-red-500 to-rose-500',
|
||||||
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
||||||
textColor: 'text-red-700 dark:text-red-400',
|
textColor: 'text-red-700 dark:text-red-400',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'income',
|
type: 'income',
|
||||||
name: 'Income',
|
nameKey: 'Income',
|
||||||
icon: ArrowDownLeft,
|
icon: ArrowDownLeft,
|
||||||
description:
|
descriptionKey:
|
||||||
'Money coming into an account from a source (e.g., salary, refunds, interest). Increases your balance.',
|
'Money coming into an account from a source (e.g., salary, refunds, interest). Increases your balance.',
|
||||||
examples: ['Salary', 'Freelance', 'Investments', 'Refunds'],
|
|
||||||
color: 'from-emerald-500 to-green-500',
|
color: 'from-emerald-500 to-green-500',
|
||||||
bgColor: 'bg-emerald-50 dark:bg-emerald-900/20',
|
bgColor: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||||
textColor: 'text-emerald-700 dark:text-emerald-400',
|
textColor: 'text-emerald-700 dark:text-emerald-400',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'transfer',
|
type: 'transfer',
|
||||||
name: 'Transfer',
|
nameKey: 'Transfer',
|
||||||
icon: Repeat,
|
icon: Repeat,
|
||||||
description:
|
descriptionKey:
|
||||||
'Moving money between accounts. It does not count in expenses or income charts.',
|
'Moving money between accounts. It does not count in expenses or income charts.',
|
||||||
examples: ['To savings', 'Credit card payment', 'Between banks'],
|
|
||||||
color: 'from-blue-500 to-cyan-500',
|
color: 'from-blue-500 to-cyan-500',
|
||||||
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
|
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
|
||||||
textColor: 'text-blue-700 dark:text-blue-400',
|
textColor: 'text-blue-700 dark:text-blue-400',
|
||||||
|
|
@ -48,8 +46,10 @@ export function StepCategoryTypes({ onContinue }: StepCategoryTypesProps) {
|
||||||
<StepHeader
|
<StepHeader
|
||||||
icon={Tag}
|
icon={Tag}
|
||||||
iconContainerClassName="bg-gradient-to-br from-violet-400 to-purple-500"
|
iconContainerClassName="bg-gradient-to-br from-violet-400 to-purple-500"
|
||||||
title="Understanding Categories"
|
title={__('Understanding Categories')}
|
||||||
description="Every transaction belongs to one of three types:"
|
description={__(
|
||||||
|
'Every transaction belongs to one of three types:',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mb-8 grid w-full max-w-3xl gap-4 md:grid-cols-3">
|
<div className="mb-8 grid w-full max-w-3xl gap-4 md:grid-cols-3">
|
||||||
|
|
@ -65,18 +65,18 @@ export function StepCategoryTypes({ onContinue }: StepCategoryTypesProps) {
|
||||||
<category.icon className="size-4 text-white" />
|
<category.icon className="size-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-semibold">
|
<h3 className="text-base font-semibold">
|
||||||
{category.name}
|
{__(category.nameKey)}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="w-full text-left text-muted-foreground/75">
|
<p className="w-full text-left text-muted-foreground/75">
|
||||||
{category.description}
|
{__(category.descriptionKey)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StepButton text="Continue" onClick={onContinue} />
|
<StepButton text={__('Continue')} onClick={onContinue} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { complete } from '@/actions/App/Http/Controllers/OnboardingController';
|
import { complete } from '@/actions/App/Http/Controllers/OnboardingController';
|
||||||
import { StepButton } from '@/components/onboarding/step-button';
|
import { StepButton } from '@/components/onboarding/step-button';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import { PartyPopper } from 'lucide-react';
|
import { PartyPopper } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
@ -34,12 +35,13 @@ export function StepComplete() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="mb-2 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">
|
<h1 className="mb-2 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">
|
||||||
You're All Set!
|
{__("You're All Set!")}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mb-8 max-w-lg text-lg text-balance text-muted-foreground">
|
<p className="mb-8 max-w-lg text-lg text-balance text-muted-foreground">
|
||||||
Your accounts are ready and your data is securely encrypted.
|
{__(
|
||||||
Welcome to Whisper Money!
|
'Your accounts are ready and your data is securely encrypted.\n Welcome to Whisper Money!',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mb-12 flex w-full max-w-md flex-col justify-center gap-4">
|
<div className="mb-12 flex w-full max-w-md flex-col justify-center gap-4">
|
||||||
|
|
@ -48,7 +50,7 @@ export function StepComplete() {
|
||||||
✓
|
✓
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Encryption Set
|
{__('Encryption Set')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-2 rounded-xl border bg-card p-2">
|
<div className="flex items-center justify-center gap-2 rounded-xl border bg-card p-2">
|
||||||
|
|
@ -56,7 +58,7 @@ export function StepComplete() {
|
||||||
✓
|
✓
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Accounts Created
|
{__('Accounts Created')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-2 rounded-xl border bg-card p-2">
|
<div className="flex items-center justify-center gap-2 rounded-xl border bg-card p-2">
|
||||||
|
|
@ -64,16 +66,16 @@ export function StepComplete() {
|
||||||
✓
|
✓
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Data Imported
|
{__('Data Imported')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StepButton
|
<StepButton
|
||||||
text="Go to Dashboard"
|
text={__('Go to Dashboard')}
|
||||||
onClick={handleComplete}
|
onClick={handleComplete}
|
||||||
loading={isRedirecting}
|
loading={isRedirecting}
|
||||||
loadingText="Redirecting..."
|
loadingText={__('Redirecting...')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { CreatedAccount } from '@/hooks/use-onboarding-state';
|
||||||
import { encrypt, importKey } from '@/lib/crypto';
|
import { encrypt, importKey } from '@/lib/crypto';
|
||||||
import { getStoredKey } from '@/lib/key-storage';
|
import { getStoredKey } from '@/lib/key-storage';
|
||||||
import { type AccountType, formatAccountType } from '@/types/account';
|
import { type AccountType, formatAccountType } from '@/types/account';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { AlertCircle, CheckCircle2, CreditCard } from 'lucide-react';
|
import { AlertCircle, CheckCircle2, CreditCard } from 'lucide-react';
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { StepButton } from './step-button';
|
import { StepButton } from './step-button';
|
||||||
|
|
@ -99,23 +100,25 @@ export function StepCreateAccount({
|
||||||
const keyString = getStoredKey();
|
const keyString = getStoredKey();
|
||||||
if (!keyString) {
|
if (!keyString) {
|
||||||
setError(
|
setError(
|
||||||
'Encryption key not available. Please go back and set up encryption.',
|
__(
|
||||||
|
'Encryption key not available. Please go back and set up encryption.',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!displayName.trim()) {
|
if (!displayName.trim()) {
|
||||||
setError('Please enter an account name.');
|
setError(__('Please enter an account name.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!type || !currencyCode) {
|
if (!type || !currencyCode) {
|
||||||
setError('Please fill in all required fields.');
|
setError(__('Please fill in all required fields.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFirstAccount && type !== 'checking') {
|
if (isFirstAccount && type !== 'checking') {
|
||||||
setError('Your first account must be a checking account.');
|
setError(__('Your first account must be a checking account.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,7 +129,7 @@ export function StepCreateAccount({
|
||||||
|
|
||||||
if (customBank) {
|
if (customBank) {
|
||||||
if (!customBank.name.trim()) {
|
if (!customBank.name.trim()) {
|
||||||
setError('Please enter a bank name.');
|
setError(__('Please enter a bank name.'));
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +140,7 @@ export function StepCreateAccount({
|
||||||
finalBankId = createdBankId;
|
finalBankId = createdBankId;
|
||||||
} else {
|
} else {
|
||||||
if (!bankId) {
|
if (!bankId) {
|
||||||
setError('Please select a bank.');
|
setError(__('Please select a bank.'));
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +194,7 @@ export function StepCreateAccount({
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
: 'Failed to create account. Please try again.',
|
: __('Failed to create account. Please try again.'),
|
||||||
);
|
);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -202,21 +205,25 @@ export function StepCreateAccount({
|
||||||
const { title, description } = useMemo(() => {
|
const { title, description } = useMemo(() => {
|
||||||
if (hasExistingAccounts) {
|
if (hasExistingAccounts) {
|
||||||
return {
|
return {
|
||||||
title: 'Your Accounts',
|
title: __('Your Accounts'),
|
||||||
description:
|
description: __(
|
||||||
"You already have accounts set up. Let's continue with the onboarding.",
|
"You already have accounts set up. Let's continue with the onboarding.",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isFirstAccount) {
|
if (isFirstAccount) {
|
||||||
return {
|
return {
|
||||||
title: 'Create an Account',
|
title: __('Create an Account'),
|
||||||
description:
|
description: __(
|
||||||
"Let's start with your main checking account. You can add more accounts later.",
|
"Let's start with your main checking account. You can add more accounts later.",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: 'Add Another Account',
|
title: __('Add Another Account'),
|
||||||
description: 'Add another account to track more of your finances.',
|
description: __(
|
||||||
|
'Add another account to track more of your finances.',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}, [hasExistingAccounts, isFirstAccount]);
|
}, [hasExistingAccounts, isFirstAccount]);
|
||||||
|
|
||||||
|
|
@ -256,7 +263,7 @@ export function StepCreateAccount({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StepButton
|
<StepButton
|
||||||
text="Continue"
|
text={__('Continue')}
|
||||||
className="w-full sm:w-full"
|
className="w-full sm:w-full"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onAccountCreated({
|
onAccountCreated({
|
||||||
|
|
@ -285,8 +292,9 @@ export function StepCreateAccount({
|
||||||
{isFirstAccount && (
|
{isFirstAccount && (
|
||||||
<div className="rounded-lg border border-blue-100 bg-blue-50 p-3 text-sm dark:border-blue-900/50 dark:bg-blue-900/20">
|
<div className="rounded-lg border border-blue-100 bg-blue-50 p-3 text-sm dark:border-blue-900/50 dark:bg-blue-900/20">
|
||||||
<p className="text-center">
|
<p className="text-center">
|
||||||
Your first account must be a{' '}
|
{__('Your first account must be a')}{' '}
|
||||||
<strong>Checking</strong> account.
|
<strong>{__('Checking')}</strong>{' '}
|
||||||
|
{__('account.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -303,8 +311,8 @@ export function StepCreateAccount({
|
||||||
className="w-full sm:w-full"
|
className="w-full sm:w-full"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
loadingText="Creating..."
|
loadingText={__('Creating...')}
|
||||||
text="Create Account"
|
text={__('Create Account')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isFirstAccount && onSkip && (
|
{!isFirstAccount && onSkip && (
|
||||||
|
|
@ -315,7 +323,7 @@ export function StepCreateAccount({
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={() => onSkip()}
|
onClick={() => onSkip()}
|
||||||
>
|
>
|
||||||
Ignore
|
{__('Ignore')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { StepButton } from '@/components/onboarding/step-button';
|
import { StepButton } from '@/components/onboarding/step-button';
|
||||||
import { StepHeader } from '@/components/onboarding/step-header';
|
import { StepHeader } from '@/components/onboarding/step-header';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Check, Settings, SkipForward } from 'lucide-react';
|
import { Check, Settings, SkipForward } from 'lucide-react';
|
||||||
|
|
||||||
interface StepCustomizeCategoriesProps {
|
interface StepCustomizeCategoriesProps {
|
||||||
|
|
@ -17,22 +18,26 @@ export function StepCustomizeCategories({
|
||||||
<StepHeader
|
<StepHeader
|
||||||
icon={Settings}
|
icon={Settings}
|
||||||
iconContainerClassName="bg-gradient-to-br from-pink-400 to-rose-500"
|
iconContainerClassName="bg-gradient-to-br from-pink-400 to-rose-500"
|
||||||
title="Customize Your Categories"
|
title={__('Customize Your Categories')}
|
||||||
description="We've created a comprehensive set of categories for you. You can customize them now or adjust them later in settings."
|
description={__(
|
||||||
|
"We've created a comprehensive set of categories for you. You can customize them now or adjust them later in settings.",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mb-8 w-full max-w-md rounded-xl border bg-card p-6">
|
<div className="mb-8 w-full max-w-md rounded-xl border bg-card p-6">
|
||||||
<h3 className="mb-4 font-semibold">Your Categories Include:</h3>
|
<h3 className="mb-4 font-semibold">
|
||||||
|
{__('Your Categories Include:')}
|
||||||
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[
|
{[
|
||||||
'Food & Dining (Groceries, Restaurants, Delivery)',
|
__('Food & Dining (Groceries, Restaurants, Delivery)'),
|
||||||
'Housing (Rent, Utilities, Maintenance)',
|
__('Housing (Rent, Utilities, Maintenance)'),
|
||||||
'Transportation (Fuel, Public Transit, Parking)',
|
__('Transportation (Fuel, Public Transit, Parking)'),
|
||||||
'Shopping (Clothing, Electronics, Gifts)',
|
__('Shopping (Clothing, Electronics, Gifts)'),
|
||||||
'Entertainment (Movies, Sports, Hobbies)',
|
__('Entertainment (Movies, Sports, Hobbies)'),
|
||||||
'Health & Wellness (Medical, Pharmacy, Fitness)',
|
__('Health & Wellness (Medical, Pharmacy, Fitness)'),
|
||||||
'Income (Salary, Freelance, Investments)',
|
__('Income (Salary, Freelance, Investments)'),
|
||||||
'Transfers (Between accounts, Savings)',
|
__('Transfers (Between accounts, Savings)'),
|
||||||
].map((category) => (
|
].map((category) => (
|
||||||
<div
|
<div
|
||||||
key={category}
|
key={category}
|
||||||
|
|
@ -43,7 +48,7 @@ export function StepCustomizeCategories({
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<span className="ml-6">...and 40+ more</span>
|
<span className="ml-6">{__('...and 40+ more')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -56,14 +61,15 @@ export function StepCustomizeCategories({
|
||||||
className="group gap-2"
|
className="group gap-2"
|
||||||
>
|
>
|
||||||
<SkipForward className="h-4 w-4" />
|
<SkipForward className="h-4 w-4" />
|
||||||
Use Defaults
|
{__('Use Defaults')}
|
||||||
</Button>
|
</Button>
|
||||||
<StepButton text="Continue" onClick={onContinue} />
|
<StepButton text={__('Continue')} onClick={onContinue} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-4 text-center text-xs text-muted-foreground">
|
<p className="mt-4 text-center text-xs text-muted-foreground">
|
||||||
You can always customize categories later in Settings →
|
{__(
|
||||||
Categories
|
'You can always customize categories later in Settings \u2192\n Categories',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { StepButton } from '@/components/onboarding/step-button';
|
import { StepButton } from '@/components/onboarding/step-button';
|
||||||
import { StepHeader } from '@/components/onboarding/step-header';
|
import { StepHeader } from '@/components/onboarding/step-header';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Eye, EyeOff, Lock, Server, Shield, User } from 'lucide-react';
|
import { Eye, EyeOff, Lock, Server, Shield, User } from 'lucide-react';
|
||||||
|
|
||||||
interface StepEncryptionExplainedProps {
|
interface StepEncryptionExplainedProps {
|
||||||
|
|
@ -14,30 +15,38 @@ export function StepEncryptionExplained({
|
||||||
<StepHeader
|
<StepHeader
|
||||||
icon={Shield}
|
icon={Shield}
|
||||||
iconContainerClassName="bg-gradient-to-br from-blue-500 to-indigo-600"
|
iconContainerClassName="bg-gradient-to-br from-blue-500 to-indigo-600"
|
||||||
title="Your Data, Your Privacy"
|
title={__('Your Data, Your Privacy')}
|
||||||
description="Whisper Money uses end-to-end encryption to protect your financial data. Here's how it works:"
|
description={__(
|
||||||
|
"Whisper Money uses end-to-end encryption to protect your financial data. Here's how it works:",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mb-5 grid w-full max-w-xl gap-4 sm:mb-4">
|
<div className="mb-5 grid w-full max-w-xl gap-4 sm:mb-4">
|
||||||
<Item
|
<Item
|
||||||
title="Create a Password"
|
title={__('Create a Password')}
|
||||||
description="Only you know this password. It never leaves your device."
|
description={__(
|
||||||
|
'Only you know this password. It never leaves your device.',
|
||||||
|
)}
|
||||||
icon={
|
icon={
|
||||||
<User className="size-4 text-emerald-600 dark:text-emerald-400" />
|
<User className="size-4 text-emerald-600 dark:text-emerald-400" />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Item
|
<Item
|
||||||
title="Data is Encrypted"
|
title={__('Data is Encrypted')}
|
||||||
description="Your data is encrypted before it leaves your browser."
|
description={__(
|
||||||
|
'Your data is encrypted before it leaves your browser.',
|
||||||
|
)}
|
||||||
icon={
|
icon={
|
||||||
<Lock className="size-4 text-blue-600 dark:text-blue-400" />
|
<Lock className="size-4 text-blue-600 dark:text-blue-400" />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Item
|
<Item
|
||||||
title="We Can't Read It"
|
title={__("We Can't Read It")}
|
||||||
description="Even we can't access your data. It's truly private."
|
description={__(
|
||||||
|
"Even we can't access your data. It's truly private.",
|
||||||
|
)}
|
||||||
icon={
|
icon={
|
||||||
<Server className="size-4 text-violet-600 dark:text-violet-400" />
|
<Server className="size-4 text-violet-600 dark:text-violet-400" />
|
||||||
}
|
}
|
||||||
|
|
@ -47,22 +56,24 @@ export function StepEncryptionExplained({
|
||||||
<div className="mb-8 flex w-full max-w-xl flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-muted-foreground/20 p-4">
|
<div className="mb-8 flex w-full max-w-xl flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-muted-foreground/20 p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Eye className="h-5 w-5 text-muted-foreground" />
|
<Eye className="h-5 w-5 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">You see:</span>
|
<span className="text-sm font-medium">
|
||||||
|
{__('You see:')}
|
||||||
|
</span>
|
||||||
<span className="font-mono text-emerald-600 dark:text-emerald-400">
|
<span className="font-mono text-emerald-600 dark:text-emerald-400">
|
||||||
STARBUCKS@TEKKA PLC
|
{__('STARBUCKS@TEKKA PLC')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<EyeOff className="h-5 w-5 text-muted-foreground" />
|
<EyeOff className="h-5 w-5 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">We see:</span>
|
<span className="text-sm font-medium">{__('We see:')}</span>
|
||||||
<span className="font-mono text-muted-foreground">
|
<span className="font-mono text-muted-foreground">
|
||||||
$KO!F6LMHU1W%TAEQFZMD9
|
{__('$KO!F6LMHU1W%TAEQFZMD9')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StepButton
|
<StepButton
|
||||||
text="I Understand, Continue"
|
text={__('I Understand, Continue')}
|
||||||
onClick={onContinue}
|
onClick={onContinue}
|
||||||
data-testid="encryption-continue-button"
|
data-testid="encryption-continue-button"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
getKeyFromPassword,
|
getKeyFromPassword,
|
||||||
} from '@/lib/crypto';
|
} from '@/lib/crypto';
|
||||||
import { storeKey } from '@/lib/key-storage';
|
import { storeKey } from '@/lib/key-storage';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { AlertCircle, CheckCircle2, KeyRound } from 'lucide-react';
|
import { AlertCircle, CheckCircle2, KeyRound } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
@ -44,12 +45,12 @@ export function StepEncryptionSetup({ onComplete }: StepEncryptionSetupProps) {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (password.length < 12) {
|
if (password.length < 12) {
|
||||||
setError('Password must be at least 12 characters');
|
setError(__('Password must be at least 12 characters'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setError('Passwords do not match');
|
setError(__('Passwords do not match'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,7 +74,7 @@ export function StepEncryptionSetup({ onComplete }: StepEncryptionSetupProps) {
|
||||||
onComplete();
|
onComplete();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Encryption setup error:', err);
|
console.error('Encryption setup error:', err);
|
||||||
setError('Failed to setup encryption. Please try again.');
|
setError(__('Failed to setup encryption. Please try again.'));
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -83,24 +84,29 @@ export function StepEncryptionSetup({ onComplete }: StepEncryptionSetupProps) {
|
||||||
<StepHeader
|
<StepHeader
|
||||||
icon={KeyRound}
|
icon={KeyRound}
|
||||||
iconContainerClassName="bg-gradient-to-br from-amber-400 to-orange-500"
|
iconContainerClassName="bg-gradient-to-br from-amber-400 to-orange-500"
|
||||||
title="Create Your Encryption Password"
|
title={__('Create Your Encryption Password')}
|
||||||
description="This password will encrypt all your financial data. Make it strong and memorable — we can't recover it for you."
|
description={__(
|
||||||
|
"This password will encrypt all your financial data. Make it strong and memorable \u2014 we can't recover it for you.",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="w-full max-w-md space-y-6">
|
<form onSubmit={handleSubmit} className="w-full max-w-md space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Encryption Password</Label>
|
<Label htmlFor="password">
|
||||||
|
{__('Encryption Password')}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Enter a strong password"
|
placeholder={__('Enter a strong password')}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
minLength={12}
|
minLength={12}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="flex flex-1 items-center gap-2">
|
||||||
<div className="flex flex-1 gap-1">
|
<div className="flex flex-1 gap-1">
|
||||||
|
|
@ -116,39 +122,45 @@ export function StepEncryptionSetup({ onComplete }: StepEncryptionSetupProps) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{passwordStrength.label}
|
{__(passwordStrength.label)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`text-xs ${password.length >= 12 ? 'text-emerald-600' : 'text-muted-foreground'}`}
|
className={`text-xs ${password.length >= 12 ? 'text-emerald-600' : 'text-muted-foreground'}`}
|
||||||
>
|
>
|
||||||
{password.length}/12 min
|
{password.length}
|
||||||
|
{__('/12 min')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
<Label htmlFor="confirmPassword">
|
||||||
|
{__('Confirm Password')}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
placeholder="Confirm your password"
|
placeholder={__('Confirm your password')}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{confirmPassword && password === confirmPassword && (
|
{confirmPassword && password === confirmPassword && (
|
||||||
<div className="flex items-center gap-1 text-xs text-emerald-600">
|
<div className="flex items-center gap-1 text-xs text-emerald-600">
|
||||||
<CheckCircle2 className="h-3 w-3" />
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
Passwords match
|
{__('Passwords match')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="storagePreference">Key Storage</Label>
|
<Label htmlFor="storagePreference">
|
||||||
|
{__('Key Storage')}
|
||||||
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={storagePreference}
|
value={storagePreference}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|
@ -163,17 +175,19 @@ export function StepEncryptionSetup({ onComplete }: StepEncryptionSetupProps) {
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="session">
|
<SelectItem value="session">
|
||||||
Session only (more secure)
|
{__('Session only (more secure)')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="persistent">
|
<SelectItem value="persistent">
|
||||||
Keep me logged in (convenient)
|
{__('Keep me logged in (convenient)')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{storagePreference === 'session'
|
{storagePreference === 'session'
|
||||||
? 'Your key will be cleared when you close the browser.'
|
? __(
|
||||||
: 'Your key will be stored until you log out.'}
|
'Your key will be cleared when you close the browser.',
|
||||||
|
)
|
||||||
|
: __('Your key will be stored until you log out.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -188,8 +202,8 @@ export function StepEncryptionSetup({ onComplete }: StepEncryptionSetupProps) {
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={processing || password.length < 12}
|
disabled={processing || password.length < 12}
|
||||||
loading={processing}
|
loading={processing}
|
||||||
loadingText="Setting up encryption..."
|
loadingText={__('Setting up encryption...')}
|
||||||
text={'Setup Encryption'}
|
text={__('Setup Encryption')}
|
||||||
className="w-full sm:w-full"
|
className="w-full sm:w-full"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { StepHeader } from '@/components/onboarding/step-header';
|
||||||
import { AmountInput } from '@/components/ui/amount-input';
|
import { AmountInput } from '@/components/ui/amount-input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { CreatedAccount } from '@/hooks/use-onboarding-state';
|
import { CreatedAccount } from '@/hooks/use-onboarding-state';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { AlertCircle, TrendingUp, Wallet } from 'lucide-react';
|
import { AlertCircle, TrendingUp, Wallet } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -24,7 +25,7 @@ export function StepImportBalances({
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (balanceInCents === 0) {
|
if (balanceInCents === 0) {
|
||||||
setError('Please enter a balance');
|
setError(__('Please enter a balance'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,15 +36,17 @@ export function StepImportBalances({
|
||||||
onComplete();
|
onComplete();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to set balance:', err);
|
console.error('Failed to set balance:', err);
|
||||||
setError('Failed to set balance. Please try again.');
|
setError(__('Failed to set balance. Please try again.'));
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const description = useMemo(() => {
|
const description = useMemo(() => {
|
||||||
return account
|
return account
|
||||||
? `"${account.name}" is a ${account.type} account. These accounts track balance changes over time instead of individual transactions.`
|
? __(
|
||||||
: 'Set the current balance for this account to start tracking.';
|
'This account tracks balance changes over time instead of individual transactions.',
|
||||||
|
)
|
||||||
|
: __('Set the current balance for this account to start tracking.');
|
||||||
}, [account]);
|
}, [account]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -51,7 +54,7 @@ export function StepImportBalances({
|
||||||
<StepHeader
|
<StepHeader
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
iconContainerClassName="bg-gradient-to-br from-amber-400 to-orange-500"
|
iconContainerClassName="bg-gradient-to-br from-amber-400 to-orange-500"
|
||||||
title="Set Account Balance"
|
title={__('Set Account Balance')}
|
||||||
description={description}
|
description={description}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -61,10 +64,13 @@ export function StepImportBalances({
|
||||||
<Wallet className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
<Wallet className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">Balance Tracking</h3>
|
<h3 className="font-semibold">
|
||||||
|
{__('Balance Tracking')}
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Perfect for investment portfolios and retirement
|
{__(
|
||||||
accounts
|
'Perfect for investment portfolios and retirement\n accounts',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -72,22 +78,22 @@ export function StepImportBalances({
|
||||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
<div className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||||
Update balances periodically to track growth
|
{__('Update balances periodically to track growth')}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
<div className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||||
Import balance history from CSV files
|
{__('Import balance history from CSV files')}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
<div className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||||
View balance evolution over time
|
{__('View balance evolution over time')}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="w-full max-w-md space-y-4">
|
<form onSubmit={handleSubmit} className="w-full max-w-md space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="balance">Current Balance</Label>
|
<Label htmlFor="balance">{__('Current Balance')}</Label>
|
||||||
<AmountInput
|
<AmountInput
|
||||||
id="balance"
|
id="balance"
|
||||||
value={balanceInCents}
|
value={balanceInCents}
|
||||||
|
|
@ -108,9 +114,9 @@ export function StepImportBalances({
|
||||||
<StepButton
|
<StepButton
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full sm:w-full"
|
className="w-full sm:w-full"
|
||||||
text="Save Balance"
|
text={__('Save Balance')}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
loadingText="Saving..."
|
loadingText={__('Saving...')}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { CreatedAccount } from '@/hooks/use-onboarding-state';
|
||||||
import { type Account, type Bank } from '@/types/account';
|
import { type Account, type Bank } from '@/types/account';
|
||||||
import { type AutomationRule } from '@/types/automation-rule';
|
import { type AutomationRule } from '@/types/automation-rule';
|
||||||
import { type Category } from '@/types/category';
|
import { type Category } from '@/types/category';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { usePage } from '@inertiajs/react';
|
import { usePage } from '@inertiajs/react';
|
||||||
import { ArrowRight, FileSpreadsheet, Upload } from 'lucide-react';
|
import { ArrowRight, FileSpreadsheet, Upload } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
@ -42,8 +43,12 @@ export function StepImportTransactions({
|
||||||
|
|
||||||
const description = useMemo(() => {
|
const description = useMemo(() => {
|
||||||
return account
|
return account
|
||||||
? `Import transactions for "${account.name}". You can export transaction history from your bank's website.`
|
? __(
|
||||||
: 'Import your transaction history to start tracking your finances.';
|
"Import transactions for your account. You can export transaction history from your bank's website.",
|
||||||
|
)
|
||||||
|
: __(
|
||||||
|
'Import your transaction history to start tracking your finances.',
|
||||||
|
);
|
||||||
}, [account]);
|
}, [account]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -51,38 +56,44 @@ export function StepImportTransactions({
|
||||||
<StepHeader
|
<StepHeader
|
||||||
icon={Upload}
|
icon={Upload}
|
||||||
iconContainerClassName="bg-gradient-to-br from-indigo-400 to-purple-500"
|
iconContainerClassName="bg-gradient-to-br from-indigo-400 to-purple-500"
|
||||||
title="Import Your Transactions"
|
title={__('Import Your Transactions')}
|
||||||
description={description}
|
description={description}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mb-4 w-full max-w-md rounded-xl border bg-card p-6">
|
<div className="mb-4 w-full max-w-md rounded-xl border bg-card p-6">
|
||||||
<h3 className="mb-4 font-semibold">
|
<h3 className="mb-4 font-semibold">
|
||||||
How to Export from Your Bank:
|
{__('How to Export from Your Bank:')}
|
||||||
</h3>
|
</h3>
|
||||||
<ol className="space-y-3 text-sm text-muted-foreground">
|
<ol className="space-y-3 text-sm text-muted-foreground">
|
||||||
<li className="flex gap-3">
|
<li className="flex gap-3">
|
||||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
|
||||||
1
|
1
|
||||||
</span>
|
</span>
|
||||||
<span>Log in to your bank's website or app</span>
|
<span>
|
||||||
|
{__("Log in to your bank's website or app")}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex gap-3">
|
<li className="flex gap-3">
|
||||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
|
||||||
2
|
2
|
||||||
</span>
|
</span>
|
||||||
<span>Go to your account's transaction history</span>
|
<span>
|
||||||
|
{__("Go to your account's transaction history")}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex gap-3">
|
<li className="flex gap-3">
|
||||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
|
||||||
3
|
3
|
||||||
</span>
|
</span>
|
||||||
<span>Look for "Export" or "Download" option</span>
|
<span>
|
||||||
|
{__('Look for "Export" or "Download" option')}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex gap-3">
|
<li className="flex gap-3">
|
||||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
|
||||||
4
|
4
|
||||||
</span>
|
</span>
|
||||||
<span>Download as CSV or Excel format</span>
|
<span>{__('Download as CSV or Excel format')}</span>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -90,9 +101,11 @@ export function StepImportTransactions({
|
||||||
<div className="mb-6 flex w-full max-w-md items-center gap-4 rounded-lg border border-dashed border-muted-foreground/30 p-4">
|
<div className="mb-6 flex w-full max-w-md items-center gap-4 rounded-lg border border-dashed border-muted-foreground/30 p-4">
|
||||||
<FileSpreadsheet className="size-10 rounded-full bg-muted p-2.5 text-muted-foreground" />
|
<FileSpreadsheet className="size-10 rounded-full bg-muted p-2.5 text-muted-foreground" />
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<p className="text-sm font-medium">Supported formats</p>
|
<p className="text-sm font-medium">
|
||||||
|
{__('Supported formats')}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
CSV, XLS, XLSX files
|
{__('CSV, XLS, XLSX files')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -104,7 +117,7 @@ export function StepImportTransactions({
|
||||||
className="group w-full gap-2 !px-8 py-6 sm:w-auto"
|
className="group w-full gap-2 !px-8 py-6 sm:w-auto"
|
||||||
>
|
>
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
Import Transactions
|
{__('Import Transactions')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{hasImported && (
|
{hasImported && (
|
||||||
|
|
@ -114,7 +127,8 @@ export function StepImportTransactions({
|
||||||
onClick={onComplete}
|
onClick={onComplete}
|
||||||
className="group gap-2"
|
className="group gap-2"
|
||||||
>
|
>
|
||||||
Continue
|
{__('Continue')}
|
||||||
|
|
||||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { StepHeader } from '@/components/onboarding/step-header';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CreatedAccount } from '@/hooks/use-onboarding-state';
|
import { CreatedAccount } from '@/hooks/use-onboarding-state';
|
||||||
import { formatAccountType } from '@/types/account';
|
import { formatAccountType } from '@/types/account';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Check, CheckCircle2, Plus, Wallet } from 'lucide-react';
|
import { Check, CheckCircle2, Plus, Wallet } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { StepButton } from './step-button';
|
import { StepButton } from './step-button';
|
||||||
|
|
||||||
interface ExistingAccount {
|
interface ExistingAccount {
|
||||||
|
|
@ -33,24 +33,22 @@ export function StepMoreAccounts({
|
||||||
onAddMore,
|
onAddMore,
|
||||||
onFinish,
|
onFinish,
|
||||||
}: StepMoreAccountsProps) {
|
}: StepMoreAccountsProps) {
|
||||||
const totalAccounts = createdAccounts.length + existingAccounts.length;
|
const description = __(
|
||||||
|
'Would you like to add more accounts or continue to the dashboard?',
|
||||||
const description = useMemo(() => {
|
);
|
||||||
return `You've set up ${totalAccounts} account${totalAccounts !== 1 ? 's' : ''}. Would you like to add more or continue to the dashboard?`;
|
|
||||||
}, [totalAccounts]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
|
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
|
||||||
<StepHeader
|
<StepHeader
|
||||||
icon={Wallet}
|
icon={Wallet}
|
||||||
iconContainerClassName="bg-gradient-to-br from-teal-400 to-cyan-500"
|
iconContainerClassName="bg-gradient-to-br from-teal-400 to-cyan-500"
|
||||||
title="Great Progress!"
|
title={__('Great Progress!')}
|
||||||
description={description}
|
description={description}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mb-8 w-full max-w-md">
|
<div className="mb-8 w-full max-w-md">
|
||||||
<h3 className="mb-3 text-sm font-medium text-muted-foreground">
|
<h3 className="mb-3 text-sm font-medium text-muted-foreground">
|
||||||
Your Accounts
|
{__('Your Accounts')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{createdAccounts.map((account) => (
|
{createdAccounts.map((account) => (
|
||||||
|
|
@ -96,10 +94,13 @@ export function StepMoreAccounts({
|
||||||
|
|
||||||
<div className="mb-6 w-full max-w-md rounded-xl border-2 border-dashed border-muted-foreground/20 p-6">
|
<div className="mb-6 w-full max-w-md rounded-xl border-2 border-dashed border-muted-foreground/20 p-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="mb-1 font-semibold">Add More Accounts?</h3>
|
<h3 className="mb-1 font-semibold">
|
||||||
|
{__('Add More Accounts?')}
|
||||||
|
</h3>
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
Track all your finances in one place — checking,
|
{__(
|
||||||
savings, credit cards, investments, and more.
|
'Track all your finances in one place \u2014 checking,\n savings, credit cards, investments, and more.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -107,12 +108,12 @@ export function StepMoreAccounts({
|
||||||
className="w-full gap-2 !py-6"
|
className="w-full gap-2 !py-6"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add Another Account
|
{__('Add Another Account')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StepButton text="Finish Setup" onClick={onFinish} />
|
<StepButton text={__('Finish Setup')} onClick={onFinish} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { StepButton } from '@/components/onboarding/step-button';
|
import { StepButton } from '@/components/onboarding/step-button';
|
||||||
import { StepHeader } from '@/components/onboarding/step-header';
|
import { StepHeader } from '@/components/onboarding/step-header';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Bot, Eye, EyeOff, Sparkles, Zap } from 'lucide-react';
|
import { Bot, Eye, EyeOff, Sparkles, Zap } from 'lucide-react';
|
||||||
|
|
||||||
interface StepSmartRulesProps {
|
interface StepSmartRulesProps {
|
||||||
|
|
@ -12,8 +13,10 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {
|
||||||
<StepHeader
|
<StepHeader
|
||||||
icon={Zap}
|
icon={Zap}
|
||||||
iconContainerClassName="bg-gradient-to-br from-yellow-400 to-amber-500"
|
iconContainerClassName="bg-gradient-to-br from-yellow-400 to-amber-500"
|
||||||
title="Smart Automation Rules"
|
title={__('Smart Automation Rules')}
|
||||||
description="Create rules to automatically categorize your transactions based on patterns you define."
|
description={__(
|
||||||
|
'Create rules to automatically categorize your transactions based on patterns you define.',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mb-5 grid w-full max-w-2xl gap-4 md:grid-cols-2">
|
<div className="mb-5 grid w-full max-w-2xl gap-4 md:grid-cols-2">
|
||||||
|
|
@ -22,11 +25,14 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {
|
||||||
<div className="mb-3 flex size-8 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
|
<div className="mb-3 flex size-8 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
|
||||||
<Sparkles className="size-5 text-emerald-600 dark:text-emerald-400" />
|
<Sparkles className="size-5 text-emerald-600 dark:text-emerald-400" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mb-2 font-semibold">Pattern Matching</h3>
|
<h3 className="mb-2 font-semibold">
|
||||||
|
{__('Pattern Matching')}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Create rules like "If description contains 'AMAZON',
|
{__(
|
||||||
categorize as Shopping"
|
'Create rules like "If description contains \'AMAZON\',\n categorize as Shopping"',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -36,12 +42,13 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {
|
||||||
<Zap className="size-5 text-blue-600 dark:text-blue-400" />
|
<Zap className="size-5 text-blue-600 dark:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mb-2 font-semibold">
|
<h3 className="mb-2 font-semibold">
|
||||||
Instant Application
|
{__('Instant Application')}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Rules apply automatically when you import new
|
{__(
|
||||||
transactions
|
'Rules apply automatically when you import new\n transactions',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -50,10 +57,10 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {
|
||||||
<div className="mb-4 flex items-center gap-3">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-amber-900 dark:text-amber-100">
|
<h3 className="font-semibold text-amber-900 dark:text-amber-100">
|
||||||
Why No AI Auto-Categorization?
|
{__('Why No AI Auto-Categorization?')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||||
Privacy comes first
|
{__('Privacy comes first')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -65,12 +72,14 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||||
AI requires sending your data to external
|
{__(
|
||||||
servers
|
'AI requires sending your data to external\n servers',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||||
This would break our end-to-end encryption
|
{__(
|
||||||
promise
|
'This would break our end-to-end encryption\n promise',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -81,10 +90,12 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||||
Your rules run entirely in your browser
|
{__('Your rules run entirely in your browser')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||||
We never see your transaction descriptions
|
{__(
|
||||||
|
'We never see your transaction descriptions',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -95,17 +106,17 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||||
You're in complete control
|
{__("You're in complete control")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||||
Create, edit, and delete rules anytime
|
{__('Create, edit, and delete rules anytime')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StepButton text="Continue to Import" onClick={onContinue} />
|
<StepButton text={__('Continue to Import')} onClick={onContinue} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { StepButton } from '@/components/onboarding/step-button';
|
import { StepButton } from '@/components/onboarding/step-button';
|
||||||
import { StepHeader } from '@/components/onboarding/step-header';
|
import { StepHeader } from '@/components/onboarding/step-header';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Bird } from 'lucide-react';
|
import { Bird } from 'lucide-react';
|
||||||
|
|
||||||
interface StepWelcomeProps {
|
interface StepWelcomeProps {
|
||||||
|
|
@ -12,16 +13,21 @@ export function StepWelcome({ onContinue }: StepWelcomeProps) {
|
||||||
<StepHeader
|
<StepHeader
|
||||||
icon={Bird}
|
icon={Bird}
|
||||||
iconContainerClassName="bg-gradient-to-br from-black to-zinc-700"
|
iconContainerClassName="bg-gradient-to-br from-black to-zinc-700"
|
||||||
title="Welcome to</br>Whisper Money"
|
title={__('Welcome to</br>Whisper Money')}
|
||||||
description="Take control of your finances with privacy-first money tracking. Let's set up your account in just a few minutes."
|
description={__(
|
||||||
|
"Take control of your finances with privacy-first money tracking. Let's set up your account in just a few minutes.",
|
||||||
|
)}
|
||||||
large
|
large
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-4 sm:w-auto">
|
<div className="flex w-full flex-col gap-4 sm:w-auto">
|
||||||
<StepButton text="Let's Get Started" onClick={onContinue} />
|
<StepButton
|
||||||
|
text={__("Let's Get Started")}
|
||||||
|
onClick={onContinue}
|
||||||
|
/>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This will take less than 5 minutes
|
{__('This will take less than 5 minutes')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
import { type SharedData } from '@/types';
|
import { type SharedData } from '@/types';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Link, usePage } from '@inertiajs/react';
|
import { Link, usePage } from '@inertiajs/react';
|
||||||
import { BirdIcon, Github, StarIcon } from 'lucide-react';
|
import { BirdIcon, Github, StarIcon } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
@ -50,6 +51,61 @@ export default function Header({
|
||||||
<span className="text-sm font-medium">Whisper Money</span>
|
<span className="text-sm font-medium">Whisper Money</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex items-center gap-2">
|
<nav className="flex items-center gap-2">
|
||||||
|
{!hideExternalButtons && (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href="https://github.com/whisper-money/whisper-money"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant={'ghost'}
|
||||||
|
className={cn([
|
||||||
|
'cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100',
|
||||||
|
{ 'hidden sm:flex': !hideAuthButtons },
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<Github className="size-5" />
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{__('Github')}
|
||||||
|
</span>
|
||||||
|
{stars !== null && (
|
||||||
|
<span className="flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-xs font-medium">
|
||||||
|
<StarIcon className="size-3 fill-amber-400 text-amber-400" />
|
||||||
|
{stars}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://discord.gg/9UQWZECDDv"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant={'ghost'}
|
||||||
|
className={cn([
|
||||||
|
'cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100',
|
||||||
|
{ 'hidden sm:flex': !hideAuthButtons },
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<DiscordIcon className="size-5" />
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
Discord
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!hideAuthButtons && !hideExternalButtons && (
|
||||||
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
className={cn([
|
||||||
|
'data-[orientation=vertical]:h-6 data-[orientation=vertical]:w-[1px] data-[orientation=vertical]:bg-border',
|
||||||
|
{ 'hidden sm:block': !hideAuthButtons },
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!hideAuthButtons && (
|
{!hideAuthButtons && (
|
||||||
<>
|
<>
|
||||||
{auth.user ? (
|
{auth.user ? (
|
||||||
|
|
@ -58,7 +114,7 @@ export default function Header({
|
||||||
size="sm"
|
size="sm"
|
||||||
className="cursor-pointer rounded-full"
|
className="cursor-pointer rounded-full"
|
||||||
>
|
>
|
||||||
Dashboard
|
{__('Dashboard')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -69,7 +125,7 @@ export default function Header({
|
||||||
size="sm"
|
size="sm"
|
||||||
className="cursor-pointer rounded-full"
|
className="cursor-pointer rounded-full"
|
||||||
>
|
>
|
||||||
Log in
|
{__('Log in')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
{canRegister && (
|
{canRegister && (
|
||||||
|
|
@ -79,7 +135,7 @@ export default function Header({
|
||||||
size="sm"
|
size="sm"
|
||||||
className="cursor-pointer rounded-full"
|
className="cursor-pointer rounded-full"
|
||||||
>
|
>
|
||||||
Register
|
{__('Register')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
} from '@/components/ui/popover';
|
} from '@/components/ui/popover';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import * as Icons from 'lucide-react';
|
import * as Icons from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
|
|
@ -117,7 +118,7 @@ export function CategoryCombobox({
|
||||||
<HelpCircle className="h-3 w-3 text-zinc-500" />
|
<HelpCircle className="h-3 w-3 text-zinc-500" />
|
||||||
</div>
|
</div>
|
||||||
<span className="truncate text-zinc-500">
|
<span className="truncate text-zinc-500">
|
||||||
Uncategorized
|
{__('Uncategorized')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -133,12 +134,13 @@ export function CategoryCombobox({
|
||||||
<PopoverContent className="p-0" align="start">
|
<PopoverContent className="p-0" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search categories..."
|
placeholder={__('Search categories...')}
|
||||||
value={filterValue}
|
value={filterValue}
|
||||||
onValueChange={setFilterValue}
|
onValueChange={setFilterValue}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CommandList ref={listRef}>
|
<CommandList ref={listRef}>
|
||||||
<CommandEmpty>No category found.</CommandEmpty>
|
<CommandEmpty>{__('No category found.')}</CommandEmpty>
|
||||||
{showUncategorized && (
|
{showUncategorized && (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value="uncategorized"
|
value="uncategorized"
|
||||||
|
|
@ -151,7 +153,7 @@ export function CategoryCombobox({
|
||||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
|
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||||
<HelpCircle className="h-3 w-3 text-zinc-500" />
|
<HelpCircle className="h-3 w-3 text-zinc-500" />
|
||||||
</div>
|
</div>
|
||||||
<span>Uncategorized</span>
|
<span>{__('Uncategorized')}</span>
|
||||||
</div>
|
</div>
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from '@/components/ui/popover';
|
} from '@/components/ui/popover';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getLabelColorClasses, LABEL_COLORS, type Label } from '@/types/label';
|
import { getLabelColorClasses, LABEL_COLORS, type Label } from '@/types/label';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { Check, ChevronsUpDown, Plus, Tag, X } from 'lucide-react';
|
import { Check, ChevronsUpDown, Plus, Tag, X } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -190,13 +191,16 @@ export function LabelCombobox({
|
||||||
<PopoverContent className="w-[300px] p-0" align="start">
|
<PopoverContent className="w-[300px] p-0" align="start">
|
||||||
<Command shouldFilter={false}>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search or create labels..."
|
placeholder={__('Search or create labels...')}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onValueChange={setInputValue}
|
onValueChange={setInputValue}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CommandList>
|
<CommandList>
|
||||||
{sortedLabels.length === 0 && !showCreateOption && (
|
{sortedLabels.length === 0 && !showCreateOption && (
|
||||||
<CommandEmpty>No labels found.</CommandEmpty>
|
<CommandEmpty>
|
||||||
|
{__('No labels found.')}
|
||||||
|
</CommandEmpty>
|
||||||
)}
|
)}
|
||||||
{allowRemoveAll && (
|
{allowRemoveAll && (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
|
|
@ -207,7 +211,7 @@ export function LabelCombobox({
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
Remove all labels
|
{__('Remove all labels')}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
)}
|
)}
|
||||||
{showCreateOption && (
|
{showCreateOption && (
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useSyncContext } from '@/contexts/sync-context';
|
import { useSyncContext } from '@/contexts/sync-context';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { CloudAlert, CloudCheck, CloudOff, RefreshCw } from 'lucide-react';
|
import { CloudAlert, CloudCheck, CloudOff, RefreshCw } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
@ -83,7 +84,7 @@ export function SyncStatusButton() {
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={`mr-2 h-4 w-4 ${syncStatus === 'syncing' ? 'animate-spin' : ''}`}
|
className={`mr-2 h-4 w-4 ${syncStatus === 'syncing' ? 'animate-spin' : ''}`}
|
||||||
/>
|
/>
|
||||||
Sync now
|
{__('Sync now')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
import { isAdmin } from '@/hooks/use-admin';
|
import { isAdmin } from '@/hooks/use-admin';
|
||||||
import { type Category } from '@/types/category';
|
import { type Category } from '@/types/category';
|
||||||
import { type Label } from '@/types/label';
|
import { type Label } from '@/types/label';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import {
|
import {
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
|
@ -74,7 +75,8 @@ export function BulkActionsBar({
|
||||||
<div className="flex items-center gap-2 pl-2 text-sm">
|
<div className="flex items-center gap-2 pl-2 text-sm">
|
||||||
{isSelectingAll ? (
|
{isSelectingAll ? (
|
||||||
<>
|
<>
|
||||||
All {displayCount} transaction
|
{__('All')}
|
||||||
|
{displayCount} transaction
|
||||||
{displayCount !== 1 ? 's' : ''} selected
|
{displayCount !== 1 ? 's' : ''} selected
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -96,12 +98,15 @@ export function BulkActionsBar({
|
||||||
className="h-auto px-0 py-1 text-xs text-primary hover:text-primary/80"
|
className="h-auto px-0 py-1 text-xs text-primary hover:text-primary/80"
|
||||||
>
|
>
|
||||||
<CheckCheck className="mr-1 h-3 w-3" />
|
<CheckCheck className="mr-1 h-3 w-3" />
|
||||||
Select all
|
{__('Select all')}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
Select all {totalFilteredCount}{' '}
|
{__('Select all')}
|
||||||
transactions matching current filter
|
{totalFilteredCount}{' '}
|
||||||
|
{__(
|
||||||
|
'transactions matching current filter',
|
||||||
|
)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
@ -129,7 +134,7 @@ export function BulkActionsBar({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={isUpdating}
|
disabled={isUpdating}
|
||||||
aria-label="More actions"
|
aria-label={__('More actions')}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -141,7 +146,7 @@ export function BulkActionsBar({
|
||||||
disabled={isUpdating}
|
disabled={isUpdating}
|
||||||
>
|
>
|
||||||
<WandSparkles className="h-4 w-4" />
|
<WandSparkles className="h-4 w-4" />
|
||||||
Re-evaluate rules
|
{__('Re-evaluate rules')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{isDeleteEnabled && (
|
{isDeleteEnabled && (
|
||||||
|
|
@ -150,7 +155,7 @@ export function BulkActionsBar({
|
||||||
onSelect={onDelete}
|
onSelect={onDelete}
|
||||||
>
|
>
|
||||||
<Trash2 />
|
<Trash2 />
|
||||||
Delete
|
{__('Delete')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
|
@ -164,7 +169,7 @@ export function BulkActionsBar({
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
disabled={isUpdating}
|
disabled={isUpdating}
|
||||||
aria-label="Clear selection"
|
aria-label={__('Clear selection')}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { CategorySelect } from '@/components/transactions/category-select';
|
import { CategorySelect } from '@/components/transactions/category-select';
|
||||||
import { type Category } from '@/types/category';
|
import { type Category } from '@/types/category';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface BulkCategorySelectProps {
|
interface BulkCategorySelectProps {
|
||||||
|
|
@ -27,7 +28,7 @@ export function BulkCategorySelect({
|
||||||
onValueChange={handleChange}
|
onValueChange={handleChange}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder="Change category"
|
placeholder={__('Change category')}
|
||||||
triggerClassName="h-9 w-[180px]"
|
triggerClassName="h-9 w-[180px]"
|
||||||
showUncategorized={true}
|
showUncategorized={true}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { LabelCombobox } from '@/components/shared/label-combobox';
|
import { LabelCombobox } from '@/components/shared/label-combobox';
|
||||||
import { type Label } from '@/types/label';
|
import { type Label } from '@/types/label';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface BulkLabelSelectProps {
|
interface BulkLabelSelectProps {
|
||||||
|
|
@ -26,7 +27,7 @@ export function BulkLabelSelect({
|
||||||
onValueChange={handleChange}
|
onValueChange={handleChange}
|
||||||
labels={labels}
|
labels={labels}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder="Add labels"
|
placeholder={__('Add labels')}
|
||||||
triggerClassName="h-9 w-[180px] min-h-9"
|
triggerClassName="h-9 w-[180px] min-h-9"
|
||||||
allowCreate={true}
|
allowCreate={true}
|
||||||
allowRemoveAll={true}
|
allowRemoveAll={true}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue