feat: (Onboarding) add categorization intro screen with benefit cards (#201)
## Summary - Adds an intro screen to the categorize transactions onboarding step that shows before the categorizer UI, explaining that at least 5 transactions must be categorized to continue - Displays four benefit cards (see where you spend, build better budgets, spot savings, automate over time) to motivate the user - Adds missing Spanish translation for \"Back to accounts\" and all new strings introduced by the intro screen
This commit is contained in:
parent
cf9071c11b
commit
a8dfac1422
|
|
@ -2,7 +2,11 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
|
@ -24,12 +28,38 @@ class OnboardingController extends Controller
|
|||
->with('bank:id,name,logo')
|
||||
->get(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank_id', 'banking_connection_id']);
|
||||
|
||||
$categories = Category::query()
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'icon', 'color', 'type']);
|
||||
|
||||
$transactions = Transaction::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('category_id')
|
||||
->with(['account.bank:id,name,logo', 'labels:id,name,color'])
|
||||
->orderBy('transaction_date', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->get(['id', 'account_id', 'category_id', 'description', 'description_iv', 'transaction_date', 'amount', 'currency_code', 'notes', 'notes_iv']);
|
||||
|
||||
return Inertia::render('onboarding/index', [
|
||||
'banks' => $banks,
|
||||
'accounts' => $accounts,
|
||||
'categories' => $categories,
|
||||
'transactions' => $transactions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function syncStatus(Request $request): JsonResponse
|
||||
{
|
||||
$pending = $request->user()
|
||||
->bankingConnections()
|
||||
->where('status', BankingConnectionStatus::Active)
|
||||
->whereNull('last_synced_at')
|
||||
->exists();
|
||||
|
||||
return response()->json(['pending' => $pending]);
|
||||
}
|
||||
|
||||
public function complete(Request $request): RedirectResponse
|
||||
{
|
||||
$request->user()->update([
|
||||
|
|
|
|||
33
lang/es.json
33
lang/es.json
|
|
@ -91,11 +91,13 @@
|
|||
"Accounts need to be mapped before syncing can begin.": "Las cuentas deben mapearse antes de que pueda comenzar la sincronización.",
|
||||
"Accounts will become manual. All transactions and balances will be preserved.": "Las cuentas pasarán a ser manuales. Todas las transacciones y saldos se conservarán.",
|
||||
"Actions": "Acciones",
|
||||
"Add another account": "Agregar otra cuenta",
|
||||
"Add Another Account": "Agregar Otra Cuenta",
|
||||
"Add Condition": "Agregar Condición",
|
||||
"Add Group": "Agregar Grupo",
|
||||
"Add Labels": "Agregar Etiquetas",
|
||||
"Add More Accounts": "Agregar Más Cuentas",
|
||||
"Add more accounts or continue to set up your categories.": "Agrega más cuentas o continúa para configurar tus categorías.",
|
||||
"Add More Accounts?": "¿Agregar Más Cuentas?",
|
||||
"Add Transaction": "Agregar Transacción",
|
||||
"Add a new bank account to track your transactions.": "Agrega una nueva cuenta bancaria para rastrear tus transacciones.",
|
||||
|
|
@ -126,6 +128,8 @@
|
|||
"All your accounts at a glance": "Todas tus cuentas de un vistazo",
|
||||
"All your money in one place. No spreadsheets. Private.": "Todo tu dinero en un solo sitio. Sin Excels. Privado.",
|
||||
"All your transactions are already categorized.": "Todas tus transacciones ya están categorizadas.",
|
||||
"All your transactions are already categorized. You are all set!": "Todas tus transacciones ya están categorizadas. ¡Estás listo!",
|
||||
"Almost there, just double-checking the math...": "Casi listo, revisando los números una vez más...",
|
||||
"Allocated Amount": "Cantidad Asignado",
|
||||
"Allocated:": "Asignado:",
|
||||
"Already have an account?": "¿Ya tienes una cuenta?",
|
||||
|
|
@ -164,13 +168,16 @@
|
|||
"Automation rules settings": "Configuración de reglas de automatización",
|
||||
"Automation rules to categorize transactions automatically.": "Reglas de automatización para categorizar transacciones automáticamente.",
|
||||
"Auto-sync transactions directly from your bank": "Sincroniza automáticamente las transacciones desde tu banco",
|
||||
"Automate over time": "Automatiza con el tiempo",
|
||||
"Available:": "Disponible:",
|
||||
"Back": "Atrás",
|
||||
"Back to accounts": "Volver a las cuentas",
|
||||
"Blue": "Azul",
|
||||
"Back to Transactions": "Volver a Transacciones",
|
||||
"Balance": "Balance",
|
||||
"Balance (Optional)": "Balance (Opcional)",
|
||||
"Balance Date": "Fecha del Balance",
|
||||
"Build better budgets": "Crea mejores presupuestos",
|
||||
"Balance History": "Historial de Balance",
|
||||
"Balance Tracking": "Seguimiento de Balance",
|
||||
"Balance evolution": "Evolución del balance",
|
||||
|
|
@ -212,6 +219,7 @@
|
|||
"Categories settings": "Configuración de categorías",
|
||||
"Categorize": "Categorizar",
|
||||
"Categorize Transactions": "Categorizar Transacciones",
|
||||
"Categorize Your Transactions": "Categoriza tus transacciones",
|
||||
"Category": "Categoría",
|
||||
"Category (Optional)": "Categoría (Opcional)",
|
||||
"Category name": "Nombre de categoría",
|
||||
|
|
@ -266,6 +274,7 @@
|
|||
"Connected account": "Cuenta conectada",
|
||||
"Connected accounts are a Standard Plan feature. You'll choose a plan at the end of the onboarding.": "Las cuentas conectadas son una función del plan Standard. Elegirás un plan al final del proceso de incorporación.",
|
||||
"Connecting...": "Conectando...",
|
||||
"Connecting the dots in your finances...": "Conectando los puntos de tus finanzas...",
|
||||
"Connections": "Conexiones",
|
||||
"Consider saving more if possible.": "Considera ahorrar más si es posible.",
|
||||
"Continue": "Continuar",
|
||||
|
|
@ -273,6 +282,8 @@
|
|||
"Continue Setup": "Continuar Configuración",
|
||||
"Continue to Import": "Continuar con Importación",
|
||||
"Country": "País",
|
||||
"Counting every penny...": "Contando cada céntimo...",
|
||||
"Crunching the numbers...": "Calculando los números...",
|
||||
"Create": "Crear",
|
||||
"Create \"": "Crear \"",
|
||||
"Create Account": "Crear Cuenta",
|
||||
|
|
@ -296,7 +307,8 @@
|
|||
"Create budgets that adapt to your spending habits and help you reach your goals.": "Crea presupuestos que se adapten a tus hábitos de gasto y te ayuden a alcanzar tus objetivos.",
|
||||
"Create new account": "Crear nueva cuenta",
|
||||
"Create rules like \"If description contains 'AMAZON', categorize as Shopping\"": "Crea reglas como \"Si la descripción contiene 'AMAZON', categorizar como Compras\"",
|
||||
"Create rules like \"If description contains \\'AMAZON\\',\\n categorize as Shopping\"": "Crea reglas como \"Si la descripción contiene \\'AMAZON\\', categorizar como Compras\"",
|
||||
"Create rules like \"If description contains \\'AMAZON\\', categorize as Shopping\"": "Crea reglas como \"Si la descripción contiene 'AMAZON', categorizar como Compras\"",
|
||||
"Create realistic budgets based on your actual spending habits.": "Crea presupuestos realistas basados en tus hábitos de gasto reales.",
|
||||
"Create rules to automatically categorize your transactions based on patterns you define.": "Crea reglas para categorizar automáticamente tus transacciones basándote en patrones que definas.",
|
||||
"Create, edit, and delete rules anytime": "Crea, edita y elimina reglas en cualquier momento",
|
||||
"Creating subscription...": "Creando suscripción...",
|
||||
|
|
@ -447,6 +459,7 @@
|
|||
"Filter rules...": "Filtrar reglas...",
|
||||
"Filter rules....": "Filtrar reglas....",
|
||||
"Filters": "Filtros",
|
||||
"Fetching your transactions...": "Obteniendo tus transacciones...",
|
||||
"Finally, a finance app that respects my privacy. Knowing my data isn't being shared gives me peace of mind.": "Por fin, una aplicación financiera que respeta mi privacidad. Saber que mis datos no se comparten me da tranquilidad.",
|
||||
"Finally, a finance app that respects my privacy. The encryption gives me peace of mind.": "Finalmente, una app de finanzas que respeta mi privacidad. La encriptación me da tranquilidad.",
|
||||
"Financial Advice Disclaimer:": "Descargo de Responsabilidad sobre Asesoramiento Financiero:",
|
||||
|
|
@ -468,6 +481,7 @@
|
|||
"Get Started for Free": "Empieza Gratis",
|
||||
"Get notified when you're close to your limit. Never overspend again.": "Recibe notificaciones cuando estés cerca de tu límite. Nunca vuelvas a gastar de más.",
|
||||
"Get help when you need it with priority email support.": "Obtén ayuda cuando la necesites con soporte prioritario por correo electrónico.",
|
||||
"Get a clear picture of where your money goes every month.": "Obtén una imagen clara de a dónde va tu dinero cada mes.",
|
||||
"Get started quickly with your existing financial data.": "Comienza rápidamente con tus datos financieros existentes.",
|
||||
"Github": "Github",
|
||||
"Go to Dashboard": "Ir al Panel",
|
||||
|
|
@ -515,6 +529,8 @@
|
|||
"If you have any questions, concerns, or feedback\\n about these Terms of Service, please contact us:": "Si tienes alguna pregunta, inquietud o comentario sobre estos Términos de Servicio, contáctanos:",
|
||||
"If you have any questions, concerns, or requests regarding this Privacy Policy or our data practices, please contact us:": "Si tienes alguna pregunta, inquietud o solicitud con respecto a esta Política de Privacidad o nuestras prácticas de datos, por favor contáctanos:",
|
||||
"If you have any questions, concerns, or requests\\n regarding this Privacy Policy or our data\\n practices, please contact us:": "Si tienes alguna pregunta, inquietud o solicitud sobre esta Política de Privacidad o nuestras prácticas de datos, contáctanos:",
|
||||
"If a transaction repeats with a certain frequency or is recurring, you can create an automatic rule for it by clicking this button.": "Si una transacción se repite con cierta frecuencia o es recurrente, puedes crear una regla automática para ella haciendo clic en este botón.",
|
||||
"Identify categories where you can cut back and save more.": "Identifica las categorías donde puedes gastar menos y ahorrar más.",
|
||||
"If you'd like to come back, here's a special offer just for you:": "Si te gustaría volver, aquí tienes una oferta especial solo para ti:",
|
||||
"If you're having trouble clicking the \"Verify Email Address\" button, copy and paste the URL below into your web browser:": "Si tienes problemas para hacer clic en el botón \"Verificar Correo Electrónico\", copia y pega la URL de abajo en tu navegador web:",
|
||||
"If you're having trouble with the import or need help with your specific bank's format, just reply to this email. I personally handle support and I'm happy to help you figure it out.": "Si tienes problemas con la importación o necesitas ayuda con el formato de tu banco, simplemente responde a este correo. Yo personalmente manejo el soporte y estaré encantado de ayudarte.",
|
||||
|
|
@ -535,6 +551,7 @@
|
|||
"Import transactions in seconds": "Importa transacciones en segundos",
|
||||
"Import your transaction history to start tracking your finances.": "Importa tu historial de transacciones para empezar a rastrear tus finanzas.",
|
||||
"Importing Transactions": "Importando Transacciones",
|
||||
"Importing your balances...": "Importando tus saldos...",
|
||||
"Income": "Ingresos",
|
||||
"Income (Salary, Freelance, Investments)": "Ingresos (Salario, Freelance, Inversiones)",
|
||||
"Income Sources": "Fuentes de Ingresos",
|
||||
|
|
@ -576,6 +593,7 @@
|
|||
"Let's Get Started": "Comencemos",
|
||||
"Let's Import Your Transactions": "Importemos Tus Transacciones",
|
||||
"Let's set up your first account to start tracking your finances.": "Configuremos tu primera cuenta para empezar a rastrear tus finanzas.",
|
||||
"Let's start": "Vamos a empezar",
|
||||
"Let's start with your main checking account. You can add more accounts later.": "Empecemos con tu cuenta corriente principal. Puedes agregar más cuentas después.",
|
||||
"Lifetime": "De por vida",
|
||||
"Lifetime License": "Licencia de Por Vida",
|
||||
|
|
@ -606,6 +624,7 @@
|
|||
"MM-DD-YYYY (e.g., 12-31-2024)": "MM-DD-YYYY (ej., 12-31-2024)",
|
||||
"Maintain and promptly update your account information": "Mantén y actualiza prontamente tu información de cuenta",
|
||||
"Maintain and promptly update your account\\n information": "Mantén y actualiza puntualmente la información de tu cuenta",
|
||||
"Making your money talk...": "Haciendo que tu dinero hable...",
|
||||
"Manage Plan": "Gestionar Plan",
|
||||
"Manage Subscription": "Gestionar Suscripción",
|
||||
"Manage your bank accounts": "Gestiona tus cuentas bancarias",
|
||||
|
|
@ -725,6 +744,7 @@
|
|||
"Paste your Binance API Secret": "Pega tu Secreto API de Binance",
|
||||
"Paste your Bitpanda API Key": "Pega tu Clave API de Bitpanda",
|
||||
"Paste your Indexa Capital API token": "Pega tu token API de Indexa Capital",
|
||||
"Patience is a virtue (especially with money)...": "La paciencia es una virtud (especialmente con el dinero)...",
|
||||
"Pattern Matching": "Coincidencia de Patrones",
|
||||
"Payment Processors:": "Procesadores de Pago:",
|
||||
"Payments are processed by third-party payment processors and subject to their terms": "Los pagos son procesados por procesadores de pago de terceros y están sujetos a sus términos",
|
||||
|
|
@ -789,6 +809,7 @@
|
|||
"Remaining": "Restante",
|
||||
"Remaining balance carries over to next period": "El saldo restante se acumula al siguiente período",
|
||||
"Remaining balance returns to available money pool": "El saldo restante vuelve al fondo de dinero disponible",
|
||||
"remaining": "restantes",
|
||||
"Remember me": "Recuérdame",
|
||||
"Remove all labels": "Eliminar todas las etiquetas",
|
||||
"Resend verification email": "Reenviar correo de verificación",
|
||||
|
|
@ -814,7 +835,7 @@
|
|||
"Rule title": "Título de regla",
|
||||
"Rules": "Reglas",
|
||||
"Rules apply automatically when you import new transactions": "Las reglas se aplican automáticamente cuando importas nuevas transacciones",
|
||||
"Rules apply automatically when you import new\\n transactions": "Las reglas se aplican automáticamente cuando importas nuevas transacciones",
|
||||
"Rules will categorize future transactions for you automatically.": "Las reglas categorizarán las transacciones futuras por ti automáticamente.",
|
||||
"STARBUCKS@TEKKA PLC": "STARBUCKS@TEKKA PLC",
|
||||
"Sarah M.": "Sarah M.",
|
||||
"Save": "Guardar",
|
||||
|
|
@ -844,6 +865,7 @@
|
|||
"Secure upload": "Carga segura",
|
||||
"See balances": "Ver balances",
|
||||
"See every account in one place. Track balances, monitor changes, and always know where you stand.": "Ve todas tus cuentas en un solo lugar. Sigue los saldos, monitoriza los cambios y sabe siempre cómo estás.",
|
||||
"See where you spend": "Ve en qué gastas",
|
||||
"See where you stand in real-time. Visual progress bars show spending vs. budget.": "Ve tu situación en tiempo real. Las barras de progreso visuales muestran el gasto frente al presupuesto.",
|
||||
"Select Account": "Seleccionar Cuenta",
|
||||
"Select a category": "Seleccionar una categoría",
|
||||
|
|
@ -909,6 +931,7 @@
|
|||
"Smart categorization": "Categorización inteligente",
|
||||
"Something not working?": "¿Algo no funciona?",
|
||||
"Spending exceeds income this period.": "El gasto excede el ingreso en este período.",
|
||||
"Spot savings opportunities": "Identifica oportunidades de ahorro",
|
||||
"Spent": "Gastado",
|
||||
"Spent:": "Gastado:",
|
||||
"Square images only (max": "Solo imágenes cuadradas (máx",
|
||||
|
|
@ -944,6 +967,7 @@
|
|||
"Take control of your finances with privacy-first money tracking. Let's set up your account in just a few minutes.": "Toma el control de tus finanzas con un seguimiento privado de tu dinero. Vamos a configurar tu cuenta en pocos minutos.",
|
||||
"Tap": "Toca",
|
||||
"Tap the": "Toca el botón",
|
||||
"Teaching the robots to count...": "Enseñándole a los robots a contar...",
|
||||
"Technical Information:": "Información Técnica:",
|
||||
"Terms of Service": "Términos de Servicio",
|
||||
"Terms of Service - Whisper Money": "Términos de Servicio - Whisper Money",
|
||||
|
|
@ -988,12 +1012,14 @@
|
|||
"This will remove your encryption key from this browser session. You'll need to enter your password again to unlock encrypted content.": "Esto eliminará tu clave de encriptación de esta sesión del navegador. Necesitarás ingresar tu contraseña nuevamente para desbloquear el contenido encriptado.",
|
||||
"This will revoke access to your bank account data from :bank.": "Esto revocará el acceso a los datos de tu cuenta bancaria de :bank.",
|
||||
"This will take less than 5 minutes": "Esto tomará menos de 5 minutos",
|
||||
"This will only take a moment.": "Esto solo tardará un momento.",
|
||||
"This will update the allocated amount for the current and future periods.": "Esto actualizará el valor asignado para el período actual y futuro.",
|
||||
"This will update the allocated amount for the\\n current and future periods.": "Esto actualizará el monto asignado para los períodos actuales y futuros.",
|
||||
"This would mean sharing your data with third parties": "Esto significaría compartir tus datos con terceros",
|
||||
"Title": "Título",
|
||||
"To": "Para",
|
||||
"To authenticate your access and protect your account security": "Para autenticar tu acceso y proteger la seguridad de tu cuenta",
|
||||
"To continue, you need to categorize at least :count transactions.": "Para continuar, debes categorizar al menos :count transacciones.",
|
||||
"To authenticate your access and protect your\\n account security": "Para autenticar tu acceso y proteger la seguridad de tu cuenta",
|
||||
"To comply with legal obligations and enforce our terms": "Para cumplir con obligaciones legales y hacer cumplir nuestros términos",
|
||||
"To comply with legal obligations and enforce\\n our terms": "Para cumplir con las obligaciones legales y hacer cumplir nuestros términos",
|
||||
|
|
@ -1364,7 +1390,6 @@
|
|||
"— $9/month": "— $9/mes",
|
||||
"← Back to home": "← Volver al inicio",
|
||||
"🎉 Get a founder discount •": "🎉 Obtén un descuento de fundador •",
|
||||
|
||||
"You're on the waiting list — Whisper Money": "Estás en la lista de espera — Whisper Money",
|
||||
"You've joined the Whisper Money waiting list. Share your referral link to move up the queue.": "Te has unido a la lista de espera de Whisper Money. Comparte tu enlace de referido para avanzar en la fila.",
|
||||
"You're on the list!": "¡Estás en la lista!",
|
||||
|
|
@ -1377,7 +1402,6 @@
|
|||
"Your email address": "Tu correo electrónico",
|
||||
"Join Waitlist": "Unirse a la lista",
|
||||
"Join the waiting list. We'll let you know when you're in.": "Únete a la lista de espera. Te avisaremos cuando sea tu turno.",
|
||||
|
||||
"You're on the Whisper Money waiting list!": "¡Estás en la lista de espera de Whisper Money!",
|
||||
"Someone just joined Whisper Money with your link!": "¡Alguien acaba de unirse a Whisper Money con tu enlace!",
|
||||
"Hey there!": "¡Hola!",
|
||||
|
|
@ -1408,7 +1432,6 @@
|
|||
"Every person who joins through your link moves you another 10 spots closer to the front — so keep sharing!": "Cada persona que se una a través de tu enlace te acerca otros 10 puestos al frente — ¡sigue compartiendo!",
|
||||
"Share Your Link Again": "Comparte Tu Enlace de Nuevo",
|
||||
"Thanks for spreading the word. It means everything to us.": "Gracias por correr la voz. Significa todo para nosotros.",
|
||||
|
||||
"Someone just overtook you in the Whisper Money queue!": "¡Alguien te acaba de adelantar en la lista de espera de Whisper Money!",
|
||||
"Someone just overtook you in the queue!": "¡Alguien te acaba de adelantar en la fila!",
|
||||
"Hey! A quick heads-up.": "¡Hola! Un aviso rápido.",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,373 @@
|
|||
import { AutomationRulesDialog } from '@/components/automation-rules/automation-rules-dialog';
|
||||
import { StepButton } from '@/components/onboarding/step-button';
|
||||
import { CategorizerCard } from '@/components/transactions/categorizer-card';
|
||||
import { CategorizerCommand } from '@/components/transactions/categorizer-command';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useCategorizeTransactions } from '@/hooks/use-categorize-transactions';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type Transaction } from '@/types/transaction';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import {
|
||||
CheckCircle2,
|
||||
PieChart,
|
||||
Settings2,
|
||||
SkipForward,
|
||||
Tag,
|
||||
Target,
|
||||
TrendingDown,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface StepCategorizeTransactionsProps {
|
||||
categories: Category[];
|
||||
accounts: Account[];
|
||||
banks: Bank[];
|
||||
transactions: Transaction[];
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function StepCategorizeTransactions({
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
transactions,
|
||||
onComplete,
|
||||
}: StepCategorizeTransactionsProps) {
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
const [showRulesHint, setShowRulesHint] = useState(false);
|
||||
const [hasSeenHint, setHasSeenHint] = useState(false);
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
isComplete,
|
||||
uncategorizedTransactions,
|
||||
currentTransaction,
|
||||
remainingCount,
|
||||
animationState,
|
||||
lastSelectedCategory,
|
||||
sortedCategories,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
rulesDialogOpen,
|
||||
setRulesDialogOpen,
|
||||
categorizedCount,
|
||||
handleCategorySelect,
|
||||
handleSkip,
|
||||
handleRulesDialogClose,
|
||||
commandInputRef,
|
||||
} = useCategorizeTransactions({
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
transactions,
|
||||
});
|
||||
|
||||
const totalAvailable = uncategorizedTransactions.length;
|
||||
const minimumRequired = Math.min(5, totalAvailable);
|
||||
const canContinue =
|
||||
isComplete ||
|
||||
categorizedCount >= minimumRequired ||
|
||||
totalAvailable === 0;
|
||||
|
||||
const hasReachedMinimum = categorizedCount >= minimumRequired;
|
||||
|
||||
// Show rules hint after first categorization, only once
|
||||
useEffect(() => {
|
||||
if (categorizedCount === 1 && !hasSeenHint) {
|
||||
setShowRulesHint(true);
|
||||
}
|
||||
}, [categorizedCount, hasSeenHint]);
|
||||
|
||||
const dismissRulesHint = useCallback(() => {
|
||||
setShowRulesHint(false);
|
||||
setHasSeenHint(true);
|
||||
}, []);
|
||||
|
||||
const handleRulesButtonClick = useCallback(() => {
|
||||
dismissRulesHint();
|
||||
setRulesDialogOpen(true);
|
||||
}, [dismissRulesHint, setRulesDialogOpen]);
|
||||
|
||||
const handleRulesDialogCloseWithHint = useCallback(
|
||||
async (open: boolean) => {
|
||||
await handleRulesDialogClose(open);
|
||||
},
|
||||
[handleRulesDialogClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.key === 'r') {
|
||||
e.preventDefault();
|
||||
handleRulesButtonClick();
|
||||
}
|
||||
if (e.ctrlKey && e.key === 'n') {
|
||||
e.preventDefault();
|
||||
if (
|
||||
animationState === 'idle' &&
|
||||
currentTransaction &&
|
||||
!showRulesHint
|
||||
) {
|
||||
handleSkip();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [
|
||||
animationState,
|
||||
currentTransaction,
|
||||
handleSkip,
|
||||
handleRulesButtonClick,
|
||||
showRulesHint,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-6">
|
||||
<Skeleton className="mx-auto h-6 w-32" />
|
||||
<Skeleton className="h-48 w-full rounded-2xl" />
|
||||
<div className="grid w-full grid-cols-3 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (totalAvailable === 0) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-6 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<CheckCircle2 className="h-8 w-8 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{__('No Uncategorized Transactions')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__(
|
||||
'All your transactions are already categorized. You are all set!',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<StepButton text={__('Continue')} onClick={onComplete} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasStarted) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-6 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-violet-100 dark:bg-violet-900/30">
|
||||
<Tag className="h-8 w-8 text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{__('Categorize Your Transactions')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__(
|
||||
'To continue, you need to categorize at least :count transactions.',
|
||||
{ count: minimumRequired },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full gap-4 md:grid-cols-2">
|
||||
<div className="rounded-xl border bg-card p-5 text-left">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-violet-100 dark:bg-violet-900/30">
|
||||
<PieChart className="size-4 text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold">
|
||||
{__('See where you spend')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__(
|
||||
'Get a clear picture of where your money goes every month.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-5 text-left">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||
<Target className="size-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold">
|
||||
{__('Build better budgets')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__(
|
||||
'Create realistic budgets based on your actual spending habits.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-5 text-left">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<TrendingDown className="size-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold">
|
||||
{__('Spot savings opportunities')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__(
|
||||
'Identify categories where you can cut back and save more.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-5 text-left">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/30">
|
||||
<Zap className="size-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold">
|
||||
{__('Automate over time')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__(
|
||||
'Rules will categorize future transactions for you automatically.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepButton
|
||||
text={__("Let's start")}
|
||||
onClick={() => setHasStarted(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Popover open={showRulesHint} onOpenChange={() => {}}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRulesButtonClick}
|
||||
className="gap-2 pr-2"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{__('Rules')}
|
||||
<Kbd>{__('Ctrl+R')}</Kbd>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-80"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm">
|
||||
{__(
|
||||
'If a transaction repeats with a certain frequency or is recurring, you can create an automatic rule for it by clicking this button.',
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={dismissRulesHint}
|
||||
className="self-end"
|
||||
>
|
||||
{__('Got it')}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSkip}
|
||||
disabled={
|
||||
animationState !== 'idle' ||
|
||||
!currentTransaction ||
|
||||
showRulesHint
|
||||
}
|
||||
className="gap-2 pr-2 text-muted-foreground"
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
{__('Skip')}
|
||||
<Kbd>{__('Ctrl+N')}</Kbd>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onComplete}
|
||||
disabled={!canContinue}
|
||||
>
|
||||
{__('Continue')}
|
||||
</Button>
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{hasReachedMinimum ? (
|
||||
<>
|
||||
<span className="font-medium text-foreground">
|
||||
{remainingCount}
|
||||
</span>{' '}
|
||||
{__('remaining')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium text-foreground">
|
||||
{categorizedCount}
|
||||
</span>
|
||||
/{minimumRequired}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categorizer card */}
|
||||
<CategorizerCard
|
||||
transaction={currentTransaction}
|
||||
animationState={animationState}
|
||||
lastSelectedCategory={lastSelectedCategory}
|
||||
/>
|
||||
|
||||
{/* Category command palette */}
|
||||
<CategorizerCommand
|
||||
sortedCategories={sortedCategories}
|
||||
animationState={animationState}
|
||||
currentTransaction={currentTransaction}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
onCategorySelect={handleCategorySelect}
|
||||
commandInputRef={commandInputRef}
|
||||
disabled={showRulesHint}
|
||||
/>
|
||||
|
||||
<AutomationRulesDialog
|
||||
open={rulesDialogOpen}
|
||||
onOpenChange={handleRulesDialogCloseWithHint}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
AccountForm,
|
||||
AccountFormData,
|
||||
} from '@/components/accounts/account-form';
|
||||
import { BankLogo } from '@/components/bank-logo';
|
||||
import { StepHeader } from '@/components/onboarding/step-header';
|
||||
import { ConnectAccountInline } from '@/components/open-banking/connect-account-inline';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -14,10 +15,11 @@ import { __ } from '@/utils/i18n';
|
|||
import { usePage } from '@inertiajs/react';
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Check,
|
||||
CreditCard,
|
||||
Link2,
|
||||
PencilLine,
|
||||
Plus,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
|
@ -25,6 +27,16 @@ import { StepButton } from './step-button';
|
|||
|
||||
type AccountMode = 'select' | 'manual' | 'connected';
|
||||
|
||||
interface CreatedAccountDisplay {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
currencyCode: string;
|
||||
bankName?: string;
|
||||
bankLogo?: string | null;
|
||||
connected?: boolean;
|
||||
}
|
||||
|
||||
interface ExistingAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -45,16 +57,18 @@ interface StepCreateAccountProps {
|
|||
banks: { id: string; name: string; logo: string | null }[];
|
||||
isFirstAccount: boolean;
|
||||
existingAccounts?: ExistingAccount[];
|
||||
createdAccounts?: CreatedAccountDisplay[];
|
||||
onAccountCreated: (account: CreatedAccount) => void;
|
||||
onSkip?: () => void;
|
||||
onContinue?: () => void;
|
||||
}
|
||||
|
||||
export function StepCreateAccount({
|
||||
banks,
|
||||
isFirstAccount,
|
||||
existingAccounts = [],
|
||||
createdAccounts = [],
|
||||
onAccountCreated,
|
||||
onSkip,
|
||||
onContinue,
|
||||
}: StepCreateAccountProps) {
|
||||
const { pricing, subscriptionsEnabled, features } =
|
||||
usePage<SharedData>().props;
|
||||
|
|
@ -63,6 +77,7 @@ export function StepCreateAccount({
|
|||
const [selectedMode, setSelectedMode] = useState<'manual' | 'connected'>(
|
||||
'manual',
|
||||
);
|
||||
const [isAddingAnother, setIsAddingAnother] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const formDataRef = useRef<AccountFormData>({
|
||||
|
|
@ -198,9 +213,14 @@ export function StepCreateAccount({
|
|||
|
||||
const accountData = await response.json();
|
||||
|
||||
const matchedBank = banks.find(
|
||||
(b) => String(b.id) === String(finalBankId),
|
||||
);
|
||||
const bankName =
|
||||
formDataRef.current.customBank?.name ??
|
||||
banks.find((b) => String(b.id) === String(finalBankId))?.name;
|
||||
formDataRef.current.customBank?.name ?? matchedBank?.name;
|
||||
const bankLogo = formDataRef.current.customBank
|
||||
? null
|
||||
: (matchedBank?.logo ?? null);
|
||||
|
||||
onAccountCreated({
|
||||
id: accountData.id || finalBankId,
|
||||
|
|
@ -208,6 +228,7 @@ export function StepCreateAccount({
|
|||
type: type,
|
||||
currencyCode: currencyCode,
|
||||
bankName,
|
||||
bankLogo,
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
} catch (err) {
|
||||
|
|
@ -222,9 +243,32 @@ export function StepCreateAccount({
|
|||
}
|
||||
|
||||
const hasExistingAccounts = existingAccounts.length > 0;
|
||||
const hasCreatedAccounts = createdAccounts.length > 0;
|
||||
|
||||
const createdAccountsByBank = useMemo(() => {
|
||||
const groups = new Map<string, CreatedAccountDisplay[]>();
|
||||
for (const account of createdAccounts) {
|
||||
const key = account.bankName ?? 'Bank';
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(account);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return Array.from(groups.entries());
|
||||
}, [createdAccounts]);
|
||||
|
||||
const existingAccountsByBank = useMemo(() => {
|
||||
const groups = new Map<string, ExistingAccount[]>();
|
||||
for (const account of existingAccounts) {
|
||||
const key = account.bank?.name ?? 'Bank';
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(account);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return Array.from(groups.entries());
|
||||
}, [existingAccounts]);
|
||||
|
||||
const { title, description } = useMemo(() => {
|
||||
if (hasExistingAccounts) {
|
||||
if (hasExistingAccounts && !isAddingAnother && !hasCreatedAccounts) {
|
||||
return {
|
||||
title: __('Your Accounts'),
|
||||
description: __(
|
||||
|
|
@ -246,10 +290,86 @@ export function StepCreateAccount({
|
|||
'Add another account to track more of your finances.',
|
||||
),
|
||||
};
|
||||
}, [hasExistingAccounts, isFirstAccount]);
|
||||
}, [
|
||||
hasExistingAccounts,
|
||||
isFirstAccount,
|
||||
isAddingAnother,
|
||||
hasCreatedAccounts,
|
||||
]);
|
||||
|
||||
// Show created accounts list view (after creating accounts in this session)
|
||||
if (hasCreatedAccounts && !isAddingAnother) {
|
||||
return (
|
||||
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
|
||||
<StepHeader
|
||||
icon={CreditCard}
|
||||
iconContainerClassName="bg-gradient-to-br from-emerald-400 to-teal-500"
|
||||
title={__('Your Accounts')}
|
||||
description={__(
|
||||
'Add more accounts or continue to set up your categories.',
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mb-6 w-full max-w-md space-y-3">
|
||||
{createdAccountsByBank.map(([bankName, accounts]) => (
|
||||
<div
|
||||
key={bankName}
|
||||
className="overflow-hidden rounded-lg border bg-card"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2.5">
|
||||
<BankLogo
|
||||
src={accounts[0].bankLogo}
|
||||
name={bankName}
|
||||
fallback="letter"
|
||||
className="h-5 w-5 text-[10px]"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{bankName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center gap-2 px-3 py-2"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
|
||||
<span className="flex-1 text-sm">
|
||||
{account.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{account.currencyCode}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
onClick={() => setIsAddingAnother(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{__('Add another account')}
|
||||
</Button>
|
||||
|
||||
<StepButton
|
||||
text={__('Continue')}
|
||||
className="w-full sm:w-full"
|
||||
onClick={onContinue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show existing accounts view
|
||||
if (hasExistingAccounts) {
|
||||
if (hasExistingAccounts && !isAddingAnother) {
|
||||
return (
|
||||
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
|
||||
<StepHeader
|
||||
|
|
@ -260,66 +380,70 @@ export function StepCreateAccount({
|
|||
/>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-6 space-y-2">
|
||||
{existingAccounts.map((account) => (
|
||||
<div className="mb-6 space-y-3">
|
||||
{existingAccountsByBank.map(([bankName, accounts]) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center gap-3 rounded-lg border bg-card p-4"
|
||||
key={bankName}
|
||||
className="overflow-hidden rounded-lg border bg-card"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2.5">
|
||||
<BankLogo
|
||||
src={accounts[0].bank?.logo}
|
||||
name={bankName}
|
||||
fallback="letter"
|
||||
className="h-5 w-5 text-[10px]"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{bankName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{account.name || 'Account'}
|
||||
</p>
|
||||
<p className="flex gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{account.bank?.name ?? `Bank`}
|
||||
</span>
|
||||
<span className="opacity-50">
|
||||
–
|
||||
</span>
|
||||
<span>
|
||||
{account.type
|
||||
.split('_')
|
||||
.map(
|
||||
(w) =>
|
||||
w
|
||||
.charAt(0)
|
||||
.toUpperCase() +
|
||||
w.slice(1),
|
||||
)
|
||||
.join(' ')}
|
||||
</span>
|
||||
<span className="opacity-50">
|
||||
–
|
||||
</span>
|
||||
<span>{account.currency_code}</span>
|
||||
</p>
|
||||
<div className="divide-y">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center gap-2 px-3 py-2"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
|
||||
<span className="flex-1 text-sm">
|
||||
{account.name || 'Account'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{account.type
|
||||
.split('_')
|
||||
.map(
|
||||
(w) =>
|
||||
w
|
||||
.charAt(0)
|
||||
.toUpperCase() +
|
||||
w.slice(1),
|
||||
)
|
||||
.join(' ')}{' '}
|
||||
· {account.currency_code}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<StepButton
|
||||
text={__('Continue')}
|
||||
className="w-full sm:w-full"
|
||||
onClick={() =>
|
||||
onAccountCreated({
|
||||
id: existingAccounts[0].id,
|
||||
name:
|
||||
existingAccounts[0].name ||
|
||||
existingAccounts[0].bank?.name ||
|
||||
'Account',
|
||||
type: existingAccounts[0].type,
|
||||
currencyCode: existingAccounts[0].currency_code,
|
||||
bankName: existingAccounts[0].bank?.name,
|
||||
connected:
|
||||
!!existingAccounts[0].banking_connection_id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
onClick={() => setIsAddingAnother(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{__('Add another account')}
|
||||
</Button>
|
||||
|
||||
<StepButton
|
||||
text={__('Continue')}
|
||||
className="w-full sm:w-full"
|
||||
onClick={onContinue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -385,19 +509,6 @@ export function StepCreateAccount({
|
|||
>
|
||||
{__('Back')}
|
||||
</Button>
|
||||
|
||||
{!isFirstAccount && onSkip && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full opacity-50 transition-all duration-200 hover:opacity-100"
|
||||
variant="ghost"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => onSkip()}
|
||||
>
|
||||
{__('Ignore')}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -494,23 +605,24 @@ export function StepCreateAccount({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<StepButton
|
||||
className="w-full sm:w-full"
|
||||
text={__('Continue')}
|
||||
onClick={() => setMode(selectedMode)}
|
||||
/>
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<StepButton
|
||||
className="w-full sm:w-full"
|
||||
text={__('Continue')}
|
||||
onClick={() => setMode(selectedMode)}
|
||||
/>
|
||||
|
||||
{!isFirstAccount && onSkip && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full opacity-50 transition-all duration-200 hover:opacity-100"
|
||||
variant="ghost"
|
||||
onClick={() => onSkip()}
|
||||
>
|
||||
{__('Ignore')}
|
||||
</Button>
|
||||
)}
|
||||
{(hasCreatedAccounts || hasExistingAccounts) && (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full opacity-50 transition-all duration-200 hover:opacity-100"
|
||||
variant="ghost"
|
||||
onClick={() => setIsAddingAnother(false)}
|
||||
>
|
||||
{__('Back to accounts')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,126 +0,0 @@
|
|||
import { StepHeader } from '@/components/onboarding/step-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CreatedAccount } from '@/hooks/use-onboarding-state';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Check, CheckCircle2, Plus, Wallet } from 'lucide-react';
|
||||
import { StepButton } from './step-button';
|
||||
|
||||
interface ExistingAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
name_iv: string | null;
|
||||
encrypted: boolean;
|
||||
type: string;
|
||||
currency_code: string;
|
||||
bank_id: string;
|
||||
bank?: {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface StepMoreAccountsProps {
|
||||
createdAccounts: CreatedAccount[];
|
||||
existingAccounts?: ExistingAccount[];
|
||||
onAddMore: () => void;
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export function StepMoreAccounts({
|
||||
createdAccounts,
|
||||
existingAccounts = [],
|
||||
onAddMore,
|
||||
onFinish,
|
||||
}: StepMoreAccountsProps) {
|
||||
const createdIds = new Set(createdAccounts.map((a) => a.id));
|
||||
const filteredExistingAccounts = existingAccounts.filter(
|
||||
(a) => !createdIds.has(a.id),
|
||||
);
|
||||
|
||||
const description = __(
|
||||
'Would you like to add more accounts or continue to the dashboard?',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
|
||||
<StepHeader
|
||||
icon={Wallet}
|
||||
iconContainerClassName="bg-gradient-to-br from-teal-400 to-cyan-500"
|
||||
title={__('Great Progress!')}
|
||||
description={description}
|
||||
/>
|
||||
|
||||
<div className="mb-8 w-full max-w-md">
|
||||
<h3 className="mb-3 text-sm font-medium text-muted-foreground">
|
||||
{__('Your Accounts')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{createdAccounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center gap-3 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{account.name}</p>
|
||||
<p className="flex gap-2 text-sm text-muted-foreground">
|
||||
<span>{account.bankName ?? 'Bank'}</span>
|
||||
<span className="opacity-50">–</span>
|
||||
<span>{account.currencyCode}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Check className="h-5 w-5 text-emerald-500" />
|
||||
</div>
|
||||
))}
|
||||
{filteredExistingAccounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center gap-3 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-muted-foreground">
|
||||
{account.name || 'Account'}
|
||||
</p>
|
||||
<p className="flex gap-2 text-sm text-muted-foreground">
|
||||
<span>{account.bank?.name ?? `Bank`}</span>
|
||||
<span className="opacity-50">–</span>
|
||||
<span>{account.currency_code}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Check className="h-5 w-5 text-emerald-500" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<h3 className="mb-1 font-semibold">
|
||||
{__('Add More Accounts?')}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{__(
|
||||
'Track all your finances in one place \u2014 checking,\n savings, credit cards, investments, and more.',
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onAddMore}
|
||||
className="w-full gap-2 !py-6"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{__('Add Another Account')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepButton text={__('Finish Setup')} onClick={onFinish} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { StepButton } from '@/components/onboarding/step-button';
|
||||
import { StepHeader } from '@/components/onboarding/step-header';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Bot, Eye, EyeOff, Sparkles, Zap } from 'lucide-react';
|
||||
import { Sparkles, Zap } from 'lucide-react';
|
||||
|
||||
interface StepSmartRulesProps {
|
||||
onContinue: () => void;
|
||||
|
|
@ -31,7 +31,7 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {
|
|||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__(
|
||||
'Create rules like "If description contains \'AMAZON\',\n categorize as Shopping"',
|
||||
'Create rules like "If description contains \'AMAZON\', categorize as Shopping"',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -47,75 +47,12 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {
|
|||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__(
|
||||
'Rules apply automatically when you import new\n transactions',
|
||||
'Rules apply automatically when you import new transactions',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 w-full max-w-2xl rounded-xl border-2 border-amber-200 bg-amber-50 p-6 dark:border-amber-900/50 dark:bg-amber-900/20">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-amber-900 dark:text-amber-100">
|
||||
{__('Why No AI Auto-Categorization?')}
|
||||
</h3>
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
{__('Privacy comes first')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<Bot className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
{__(
|
||||
'AI requires sending your data to external\n servers',
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
{__(
|
||||
'This would mean sharing your data with third parties',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<EyeOff className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
{__('Your rules run entirely in your browser')}
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
{__(
|
||||
'We never see your transaction descriptions',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
|
||||
<Eye className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
{__("You're in complete control")}
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
{__('Create, edit, and delete rules anytime')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepButton text={__('Continue')} onClick={onContinue} />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
import { syncStatus } from '@/routes/onboarding';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { router } from '@inertiajs/react';
|
||||
import axios from 'axios';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const MESSAGES = [
|
||||
'Importing your balances...',
|
||||
'Fetching your transactions...',
|
||||
'Teaching the robots to count...',
|
||||
'Making your money talk...',
|
||||
'Crunching the numbers...',
|
||||
'Patience is a virtue (especially with money)...',
|
||||
'Counting every penny...',
|
||||
'Connecting the dots in your finances...',
|
||||
'Almost there, just double-checking the math...',
|
||||
] as const;
|
||||
|
||||
interface StepSyncingProps {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function StepSyncing({ onComplete }: StepSyncingProps) {
|
||||
const [messageIndex, setMessageIndex] = useState(0);
|
||||
const [isPending, setIsPending] = useState<boolean | null>(null);
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
onCompleteRef.current = onComplete;
|
||||
|
||||
const advance = useCallback((hadPendingSync: boolean) => {
|
||||
if (hadPendingSync) {
|
||||
// Reload transactions so the categorize step sees newly synced data.
|
||||
router.reload({
|
||||
only: ['transactions'],
|
||||
onFinish: () => onCompleteRef.current(),
|
||||
});
|
||||
} else {
|
||||
// Sync was never pending (e.g. CSV-import user): skip the reload so
|
||||
// the transactions prop stays as it was at page load.
|
||||
onCompleteRef.current();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Check sync status immediately on mount, then poll every 3 seconds
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let pollTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const check = async (hadPendingSync: boolean = false) => {
|
||||
try {
|
||||
const { data } = await axios.get<{ pending: boolean }>(
|
||||
syncStatus().url,
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.pending) {
|
||||
setIsPending(false);
|
||||
advance(hadPendingSync);
|
||||
} else {
|
||||
setIsPending(true);
|
||||
pollTimer = setTimeout(() => check(true), 3000);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
// On error, advance anyway to not block the user
|
||||
advance(hadPendingSync);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
check();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(pollTimer);
|
||||
};
|
||||
}, [advance]);
|
||||
|
||||
// Rotate through messages every 2 seconds while pending
|
||||
useEffect(() => {
|
||||
if (!isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setMessageIndex((i) => (i + 1) % MESSAGES.length);
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isPending]);
|
||||
|
||||
// Don't render anything until we know sync is pending
|
||||
if (!isPending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-8 py-8 text-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-violet-100 dark:bg-violet-900/30">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-base font-medium text-foreground transition-all duration-500">
|
||||
{__(MESSAGES[messageIndex])}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('This will only take a moment.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import { AccountName } from '@/components/accounts/account-name';
|
||||
import { BankLogo } from '@/components/bank-logo';
|
||||
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||
import { type AnimationState } from '@/hooks/use-categorize-transactions';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
import { formatDateLong } from '@/utils/date';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface CategorizerCardProps {
|
||||
transaction: DecryptedTransaction | undefined;
|
||||
animationState: AnimationState;
|
||||
lastSelectedCategory: Category | null;
|
||||
}
|
||||
|
||||
export function CategorizerCard({
|
||||
transaction,
|
||||
animationState,
|
||||
lastSelectedCategory,
|
||||
}: CategorizerCardProps) {
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{animationState === 'success' && lastSelectedCategory && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-categorizer-success-pop flex flex-col items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-16 w-16 items-center justify-center rounded-full',
|
||||
getCategoryColorClasses(
|
||||
lastSelectedCategory.color,
|
||||
).bg,
|
||||
)}
|
||||
>
|
||||
<CheckCircle2
|
||||
className={cn(
|
||||
'h-8 w-8',
|
||||
getCategoryColorClasses(
|
||||
lastSelectedCategory.color,
|
||||
).text,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
|
||||
{lastSelectedCategory.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{animationState !== 'success' && transaction && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl shadow-zinc-200/50 transition-all duration-300 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-zinc-900/50',
|
||||
animationState === 'exiting' &&
|
||||
'translate-y-[-20px] scale-95 opacity-0',
|
||||
animationState === 'entering' &&
|
||||
'animate-categorizer-card-enter',
|
||||
animationState === 'idle' &&
|
||||
'translate-y-0 scale-100 opacity-100',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{formatDateLong(
|
||||
transaction.transaction_date,
|
||||
locale,
|
||||
)}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl whitespace-pre-wrap text-zinc-900 dark:text-zinc-100">
|
||||
{transaction.decryptedDescription ||
|
||||
'Encrypted'}
|
||||
</h2>
|
||||
|
||||
{transaction.account && (
|
||||
<div className="flex items-center gap-2">
|
||||
<BankLogo
|
||||
src={transaction.bank?.logo}
|
||||
name={transaction.bank?.name}
|
||||
className="size-5"
|
||||
/>
|
||||
<AccountName
|
||||
account={transaction.account}
|
||||
className="text-sm text-zinc-600 dark:text-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{transaction.decryptedNotes && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{transaction.decryptedNotes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 font-mono text-xl text-muted-foreground">
|
||||
<AmountDisplay
|
||||
amountInCents={transaction.amount}
|
||||
currencyCode={transaction.currency_code}
|
||||
variant="positive-highlight"
|
||||
highlightPositive={transaction.amount >= 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes categorizer-card-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes categorizer-success-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-categorizer-card-enter {
|
||||
animation: categorizer-card-enter 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-categorizer-success-pop {
|
||||
animation: categorizer-success-pop 0.4s ease-out forwards;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import { CategoryIcon } from '@/components/shared/category-combobox';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
import { type AnimationState } from '@/hooks/use-categorize-transactions';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { ArrowDown, ArrowUp } from 'lucide-react';
|
||||
import { type RefObject } from 'react';
|
||||
|
||||
interface CategorizerCommandProps {
|
||||
sortedCategories: Category[];
|
||||
animationState: AnimationState;
|
||||
currentTransaction: DecryptedTransaction | undefined;
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onCategorySelect: (category: Category) => void;
|
||||
commandInputRef: RefObject<HTMLInputElement | null>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function CategorizerCommand({
|
||||
sortedCategories,
|
||||
animationState,
|
||||
currentTransaction,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
onCategorySelect,
|
||||
commandInputRef,
|
||||
disabled = false,
|
||||
}: CategorizerCommandProps) {
|
||||
if (animationState === 'success' || !currentTransaction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-4 px-6 pt-2 transition-all duration-300',
|
||||
animationState === 'exiting' && 'translate-y-[-10px] opacity-0',
|
||||
animationState === 'entering' &&
|
||||
'animate-categorizer-command-enter',
|
||||
animationState === 'idle' && 'translate-y-0 opacity-100',
|
||||
disabled && 'pointer-events-none opacity-40',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-medium">{__('Assign a new category')}</h3>
|
||||
<p className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
{__('Search, move')}{' '}
|
||||
<Kbd>
|
||||
<ArrowUp className="size-3" />
|
||||
</Kbd>
|
||||
<Kbd>
|
||||
<ArrowDown className="size-3" />
|
||||
</Kbd>
|
||||
{__(', and press')}
|
||||
<Kbd>⏎</Kbd>
|
||||
</p>
|
||||
</div>
|
||||
<Command
|
||||
className="rounded-xl border border-zinc-200 bg-white shadow-lg dark:border-zinc-800 dark:bg-zinc-900"
|
||||
shouldFilter={true}
|
||||
>
|
||||
<CommandInput
|
||||
ref={commandInputRef}
|
||||
placeholder={__('Search categories...')}
|
||||
value={searchValue}
|
||||
onValueChange={onSearchChange}
|
||||
disabled={animationState !== 'idle' || disabled}
|
||||
/>
|
||||
|
||||
<CommandList className="max-h-64">
|
||||
<CommandEmpty>{__('No categories found.')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sortedCategories.map((category) => {
|
||||
const colorClasses = getCategoryColorClasses(
|
||||
category.color,
|
||||
);
|
||||
return (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
value={category.name}
|
||||
onSelect={() => onCategorySelect(category)}
|
||||
disabled={
|
||||
animationState !== 'idle' || disabled
|
||||
}
|
||||
className="group cursor-pointer gap-3 p-2"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded-full transition-transform duration-200 group-data-[selected=true]:scale-110',
|
||||
colorClasses.bg,
|
||||
)}
|
||||
>
|
||||
<CategoryIcon category={category} />
|
||||
</div>
|
||||
<span className="flex-1 truncate font-medium">
|
||||
{category.name}
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
<style>{`
|
||||
@keyframes categorizer-command-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-categorizer-command-enter {
|
||||
animation: categorizer-command-enter 0.3s ease-out 0.1s forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,416 @@
|
|||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { decrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { evaluateRules } from '@/lib/rule-engine';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type AutomationRule } from '@/types/automation-rule';
|
||||
import { type Category } from '@/types/category';
|
||||
import {
|
||||
type DecryptedTransaction,
|
||||
type Transaction,
|
||||
} from '@/types/transaction';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export type AnimationState = 'idle' | 'exiting' | 'entering' | 'success';
|
||||
|
||||
const CATEGORY_USAGE_KEY = 'category-usage-order';
|
||||
|
||||
export function getCategoryUsageOrder(): string[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(CATEGORY_USAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function updateCategoryUsageOrder(categoryId: string): void {
|
||||
try {
|
||||
const current = getCategoryUsageOrder();
|
||||
const filtered = current.filter((id) => id !== categoryId);
|
||||
const updated = [categoryId, ...filtered];
|
||||
localStorage.setItem(CATEGORY_USAGE_KEY, JSON.stringify(updated));
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
function sortCategoriesByUsage(
|
||||
categories: Category[],
|
||||
usageOrder: string[],
|
||||
): Category[] {
|
||||
const orderMap = new Map(usageOrder.map((id, index) => [id, index]));
|
||||
|
||||
return [...categories].sort((a, b) => {
|
||||
const aIndex = orderMap.get(a.id) ?? Infinity;
|
||||
const bIndex = orderMap.get(b.id) ?? Infinity;
|
||||
|
||||
if (aIndex === bIndex) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
}
|
||||
|
||||
interface UseCategorizeTransactionsOptions {
|
||||
categories: Category[];
|
||||
accounts: Account[];
|
||||
banks: Bank[];
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
export function useCategorizeTransactions({
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
transactions: initialTransactions,
|
||||
}: UseCategorizeTransactionsOptions) {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const { automationRules: sharedAutomationRules } = usePage<{
|
||||
automationRules: AutomationRule[];
|
||||
}>().props;
|
||||
|
||||
const [uncategorizedTransactions, setUncategorizedTransactions] = useState<
|
||||
DecryptedTransaction[]
|
||||
>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [animationState, setAnimationState] =
|
||||
useState<AnimationState>('idle');
|
||||
const [categoryUsageOrder, setCategoryUsageOrder] = useState<string[]>([]);
|
||||
const [lastSelectedCategory, setLastSelectedCategory] =
|
||||
useState<Category | null>(null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [rulesDialogOpen, setRulesDialogOpen] = useState(false);
|
||||
const [encryptionKey, setEncryptionKey] = useState<CryptoKey | null>(null);
|
||||
const [categorizedCount, setCategorizedCount] = useState(0);
|
||||
const commandInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const automationRules = useMemo(
|
||||
() =>
|
||||
sharedAutomationRules.map((rule) => ({
|
||||
...rule,
|
||||
rules_json:
|
||||
typeof rule.rules_json === 'string'
|
||||
? JSON.parse(rule.rules_json)
|
||||
: rule.rules_json,
|
||||
})) as AutomationRule[],
|
||||
[sharedAutomationRules],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCategoryUsageOrder(getCategoryUsageOrder());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && animationState === 'idle') {
|
||||
commandInputRef.current?.focus();
|
||||
}
|
||||
}, [isLoading, animationState, currentIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
async function decryptTransactions() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const accountsMap = new Map(
|
||||
accounts.map((account) => [account.id, account]),
|
||||
);
|
||||
const banksMap = new Map(banks.map((bank) => [bank.id, bank]));
|
||||
|
||||
const keyString = getStoredKey();
|
||||
let key: CryptoKey | null = null;
|
||||
|
||||
if (keyString && isKeySet) {
|
||||
try {
|
||||
key = await importKey(keyString);
|
||||
setEncryptionKey(key);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to import encryption key:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const decrypted = await Promise.all(
|
||||
initialTransactions.map(async (transaction) => {
|
||||
try {
|
||||
let decryptedDescription = '';
|
||||
let decryptedNotes: string | null = null;
|
||||
|
||||
if (!transaction.description_iv) {
|
||||
decryptedDescription = transaction.description;
|
||||
decryptedNotes = transaction.notes || null;
|
||||
} else if (key) {
|
||||
try {
|
||||
decryptedDescription = await decrypt(
|
||||
transaction.description,
|
||||
key,
|
||||
transaction.description_iv,
|
||||
);
|
||||
|
||||
if (
|
||||
transaction.notes &&
|
||||
transaction.notes_iv
|
||||
) {
|
||||
decryptedNotes = await decrypt(
|
||||
transaction.notes,
|
||||
key,
|
||||
transaction.notes_iv,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to decrypt transaction:',
|
||||
transaction.id,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const account = accountsMap.get(
|
||||
transaction.account_id,
|
||||
);
|
||||
const bank = account?.bank?.id
|
||||
? banksMap.get(account.bank.id)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...transaction,
|
||||
decryptedDescription,
|
||||
decryptedNotes,
|
||||
account,
|
||||
category: null,
|
||||
bank,
|
||||
} as DecryptedTransaction;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to process transaction:',
|
||||
transaction.id,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const validTransactions = decrypted.filter(
|
||||
(transaction): transaction is DecryptedTransaction =>
|
||||
transaction !== null,
|
||||
);
|
||||
|
||||
validTransactions.sort((a, b) => {
|
||||
const dateA = parseISO(a.transaction_date).getTime();
|
||||
const dateB = parseISO(b.transaction_date).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
setUncategorizedTransactions(validTransactions);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to load uncategorized transactions:',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
decryptTransactions();
|
||||
}, [initialTransactions, accounts, banks, isKeySet]);
|
||||
|
||||
const currentTransaction = uncategorizedTransactions[currentIndex];
|
||||
const remainingCount = uncategorizedTransactions.length - currentIndex;
|
||||
const isComplete = currentIndex >= uncategorizedTransactions.length;
|
||||
|
||||
const sortedCategories = useMemo(() => {
|
||||
return sortCategoriesByUsage(categories, categoryUsageOrder);
|
||||
}, [categories, categoryUsageOrder]);
|
||||
|
||||
const applyAutomationRulesToQueue = useCallback(async () => {
|
||||
if (
|
||||
!encryptionKey ||
|
||||
!automationRules.length ||
|
||||
uncategorizedTransactions.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingTransactions =
|
||||
uncategorizedTransactions.slice(currentIndex);
|
||||
let appliedCount = 0;
|
||||
const newUncategorizedList: DecryptedTransaction[] = [
|
||||
...uncategorizedTransactions.slice(0, currentIndex),
|
||||
];
|
||||
|
||||
for (const transaction of remainingTransactions) {
|
||||
const result = await evaluateRules(
|
||||
transaction,
|
||||
automationRules,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
encryptionKey,
|
||||
);
|
||||
|
||||
if (result?.categoryId) {
|
||||
try {
|
||||
await transactionSyncService.update(transaction.id, {
|
||||
category_id: result.categoryId,
|
||||
});
|
||||
appliedCount++;
|
||||
|
||||
const matchedCategory = categories.find(
|
||||
(c) => c.id === result.categoryId,
|
||||
);
|
||||
if (matchedCategory) {
|
||||
updateCategoryUsageOrder(matchedCategory.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to apply automation rule to transaction:',
|
||||
error,
|
||||
);
|
||||
newUncategorizedList.push(transaction);
|
||||
}
|
||||
} else {
|
||||
newUncategorizedList.push(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
if (appliedCount > 0) {
|
||||
setCategoryUsageOrder(getCategoryUsageOrder());
|
||||
setUncategorizedTransactions(newUncategorizedList);
|
||||
setCurrentIndex(
|
||||
Math.min(currentIndex, newUncategorizedList.length),
|
||||
);
|
||||
}
|
||||
|
||||
return appliedCount;
|
||||
}, [
|
||||
encryptionKey,
|
||||
automationRules,
|
||||
uncategorizedTransactions,
|
||||
currentIndex,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
]);
|
||||
|
||||
const handleRulesDialogClose = useCallback(
|
||||
async (open: boolean) => {
|
||||
setRulesDialogOpen(open);
|
||||
|
||||
if (!open) {
|
||||
await applyAutomationRulesToQueue();
|
||||
commandInputRef.current?.focus();
|
||||
}
|
||||
},
|
||||
[applyAutomationRulesToQueue],
|
||||
);
|
||||
|
||||
const handleCategorySelect = useCallback(
|
||||
async (category: Category) => {
|
||||
if (!currentTransaction || animationState !== 'idle') {
|
||||
return;
|
||||
}
|
||||
|
||||
setLastSelectedCategory(category);
|
||||
setAnimationState('exiting');
|
||||
|
||||
try {
|
||||
await transactionSyncService.update(currentTransaction.id, {
|
||||
category_id: category.id,
|
||||
});
|
||||
|
||||
updateCategoryUsageOrder(category.id);
|
||||
setCategoryUsageOrder(getCategoryUsageOrder());
|
||||
setCategorizedCount((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
console.error('Failed to update transaction:', error);
|
||||
setAnimationState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setAnimationState('success');
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentIndex((prev) => prev + 1);
|
||||
setAnimationState('entering');
|
||||
setSearchValue('');
|
||||
|
||||
setTimeout(() => {
|
||||
setAnimationState('idle');
|
||||
setLastSelectedCategory(null);
|
||||
commandInputRef.current?.focus();
|
||||
}, 300);
|
||||
}, 400);
|
||||
}, 300);
|
||||
},
|
||||
[currentTransaction, animationState],
|
||||
);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
if (animationState !== 'idle') {
|
||||
return;
|
||||
}
|
||||
|
||||
setAnimationState('exiting');
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentIndex((prev) => prev + 1);
|
||||
setAnimationState('entering');
|
||||
|
||||
setTimeout(() => {
|
||||
setAnimationState('idle');
|
||||
}, 300);
|
||||
}, 300);
|
||||
}, [animationState]);
|
||||
|
||||
const handlePrevious = useCallback(() => {
|
||||
if (animationState !== 'idle' || currentIndex === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAnimationState('exiting');
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentIndex((prev) => prev - 1);
|
||||
setAnimationState('entering');
|
||||
|
||||
setTimeout(() => {
|
||||
setAnimationState('idle');
|
||||
}, 300);
|
||||
}, 300);
|
||||
}, [animationState, currentIndex]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isComplete,
|
||||
uncategorizedTransactions,
|
||||
currentTransaction,
|
||||
currentIndex,
|
||||
remainingCount,
|
||||
animationState,
|
||||
lastSelectedCategory,
|
||||
sortedCategories,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
rulesDialogOpen,
|
||||
setRulesDialogOpen,
|
||||
categorizedCount,
|
||||
handleCategorySelect,
|
||||
handleSkip,
|
||||
handlePrevious,
|
||||
handleRulesDialogClose,
|
||||
commandInputRef,
|
||||
};
|
||||
}
|
||||
|
|
@ -7,9 +7,10 @@ export type OnboardingStep =
|
|||
| 'category-types'
|
||||
| 'customize-categories'
|
||||
| 'smart-rules'
|
||||
| 'syncing'
|
||||
| 'import-transactions'
|
||||
| 'import-balances'
|
||||
| 'more-accounts'
|
||||
| 'categorize-transactions'
|
||||
| 'complete';
|
||||
|
||||
// Primary steps shown in the progress indicator
|
||||
|
|
@ -20,7 +21,8 @@ const PRIMARY_STEPS: OnboardingStep[] = [
|
|||
'create-account',
|
||||
'category-types',
|
||||
'smart-rules',
|
||||
'more-accounts',
|
||||
'syncing',
|
||||
'categorize-transactions',
|
||||
'complete',
|
||||
];
|
||||
|
||||
|
|
@ -41,6 +43,7 @@ export interface CreatedAccount {
|
|||
type: string;
|
||||
currencyCode: string;
|
||||
bankName?: string;
|
||||
bankLogo?: string | null;
|
||||
connected?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ interface OnboardingLayoutProps {
|
|||
currentStep: number;
|
||||
totalSteps: number;
|
||||
stepKey: string;
|
||||
align?: 'start' | 'center';
|
||||
}
|
||||
|
||||
export default function OnboardingLayout({
|
||||
|
|
@ -13,6 +14,7 @@ export default function OnboardingLayout({
|
|||
currentStep,
|
||||
totalSteps,
|
||||
stepKey,
|
||||
align = 'start',
|
||||
}: PropsWithChildren<OnboardingLayoutProps>) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
|
|
@ -51,7 +53,12 @@ export default function OnboardingLayout({
|
|||
<div className="size-6"></div>
|
||||
</header>
|
||||
|
||||
<main className="flex flex-1 flex-col items-center justify-start px-4 pt-12 sm:justify-center md:px-6">
|
||||
<main
|
||||
className={cn(
|
||||
'flex flex-1 flex-col items-center justify-start px-4 pt-12 md:px-6',
|
||||
align === 'center' && 'sm:justify-center',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
key={stepKey}
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { StepAccountTypes } from '@/components/onboarding/step-account-types';
|
||||
import { StepCategorizeTransactions } from '@/components/onboarding/step-categorize-transactions';
|
||||
import { StepCategoryTypes } from '@/components/onboarding/step-category-types';
|
||||
import { StepComplete } from '@/components/onboarding/step-complete';
|
||||
import { StepCreateAccount } from '@/components/onboarding/step-create-account';
|
||||
import { StepCustomizeCategories } from '@/components/onboarding/step-customize-categories';
|
||||
import { StepImportBalances } from '@/components/onboarding/step-import-balances';
|
||||
import { StepImportTransactions } from '@/components/onboarding/step-import-transactions';
|
||||
import { StepMoreAccounts } from '@/components/onboarding/step-more-accounts';
|
||||
import { StepSmartRules } from '@/components/onboarding/step-smart-rules';
|
||||
import { StepSyncing } from '@/components/onboarding/step-syncing';
|
||||
import { StepWelcome } from '@/components/onboarding/step-welcome';
|
||||
import { useSyncContext } from '@/contexts/sync-context';
|
||||
import {
|
||||
|
|
@ -15,7 +16,9 @@ import {
|
|||
useOnboardingState,
|
||||
} from '@/hooks/use-onboarding-state';
|
||||
import OnboardingLayout from '@/layouts/onboarding-layout';
|
||||
import { type Bank } from '@/types/account';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type Transaction } from '@/types/transaction';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
|
@ -39,6 +42,8 @@ interface ExistingAccount {
|
|||
interface OnboardingProps {
|
||||
banks: Bank[];
|
||||
accounts: ExistingAccount[];
|
||||
categories: Category[];
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
const VALID_STEPS: OnboardingStep[] = [
|
||||
|
|
@ -50,11 +55,17 @@ const VALID_STEPS: OnboardingStep[] = [
|
|||
'category-types',
|
||||
'customize-categories',
|
||||
'smart-rules',
|
||||
'more-accounts',
|
||||
'syncing',
|
||||
'categorize-transactions',
|
||||
'complete',
|
||||
];
|
||||
|
||||
export default function Onboarding({ banks, accounts }: OnboardingProps) {
|
||||
export default function Onboarding({
|
||||
banks,
|
||||
accounts,
|
||||
categories,
|
||||
transactions,
|
||||
}: OnboardingProps) {
|
||||
const { sync } = useSyncContext();
|
||||
const hasSyncedRef = useRef(false);
|
||||
|
||||
|
|
@ -99,11 +110,8 @@ export default function Onboarding({ banks, accounts }: OnboardingProps) {
|
|||
|
||||
// Connected accounts (bank-linked) don't need manual import steps
|
||||
if (account.connected) {
|
||||
if (createdAccounts.length === 0) {
|
||||
goToStep('category-types');
|
||||
} else {
|
||||
goToStep('more-accounts');
|
||||
}
|
||||
addCreatedAccount(account);
|
||||
goToStep('create-account');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -124,23 +132,10 @@ export default function Onboarding({ banks, accounts }: OnboardingProps) {
|
|||
// Sync after import to ensure data is consistent
|
||||
await sync();
|
||||
|
||||
// After first account import, go to category steps
|
||||
// After subsequent accounts, go back to more-accounts
|
||||
if (createdAccounts.length === 1) {
|
||||
goToStep('category-types');
|
||||
} else {
|
||||
goToStep('more-accounts');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMoreAccounts = () => {
|
||||
// Always return to create-account so the user can add more accounts or continue
|
||||
goToStep('create-account');
|
||||
};
|
||||
|
||||
const handleFinishOnboarding = () => {
|
||||
goToStep('complete');
|
||||
};
|
||||
|
||||
const renderStep = () => {
|
||||
const lastAccount = createdAccounts[createdAccounts.length - 1];
|
||||
|
||||
|
|
@ -154,13 +149,13 @@ export default function Onboarding({ banks, accounts }: OnboardingProps) {
|
|||
case 'create-account':
|
||||
return (
|
||||
<StepCreateAccount
|
||||
key={createdAccounts.length}
|
||||
banks={banks}
|
||||
isFirstAccount={isFirstAccount}
|
||||
existingAccounts={accounts}
|
||||
createdAccounts={createdAccounts}
|
||||
onAccountCreated={handleAccountCreated}
|
||||
onSkip={() => {
|
||||
goToStep('more-accounts');
|
||||
}}
|
||||
onContinue={goNext}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -178,6 +173,9 @@ export default function Onboarding({ banks, accounts }: OnboardingProps) {
|
|||
case 'smart-rules':
|
||||
return <StepSmartRules onContinue={goNext} />;
|
||||
|
||||
case 'syncing':
|
||||
return <StepSyncing onComplete={goNext} />;
|
||||
|
||||
case 'import-transactions':
|
||||
return (
|
||||
<StepImportTransactions
|
||||
|
|
@ -194,13 +192,14 @@ export default function Onboarding({ banks, accounts }: OnboardingProps) {
|
|||
/>
|
||||
);
|
||||
|
||||
case 'more-accounts':
|
||||
case 'categorize-transactions':
|
||||
return (
|
||||
<StepMoreAccounts
|
||||
createdAccounts={createdAccounts}
|
||||
existingAccounts={accounts}
|
||||
onAddMore={handleAddMoreAccounts}
|
||||
onFinish={handleFinishOnboarding}
|
||||
<StepCategorizeTransactions
|
||||
categories={categories}
|
||||
accounts={accounts as unknown as Account[]}
|
||||
banks={banks}
|
||||
transactions={transactions}
|
||||
onComplete={goNext}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -220,9 +219,10 @@ export default function Onboarding({ banks, accounts }: OnboardingProps) {
|
|||
'category-types': __('Categories'),
|
||||
'customize-categories': __('Customize Categories'),
|
||||
'smart-rules': __('Smart Rules'),
|
||||
syncing: __('Syncing'),
|
||||
'import-transactions': __('Import Transactions'),
|
||||
'import-balances': __('Set Balance'),
|
||||
'more-accounts': __('Add More Accounts'),
|
||||
'categorize-transactions': __('Categorize Transactions'),
|
||||
complete: __('All Set!'),
|
||||
};
|
||||
return titles[step];
|
||||
|
|
|
|||
|
|
@ -1,52 +1,27 @@
|
|||
import { categorize as categorizeRoute } from '@/actions/App/Http/Controllers/TransactionController';
|
||||
import { AccountName } from '@/components/accounts/account-name';
|
||||
import { AutomationRulesDialog } from '@/components/automation-rules/automation-rules-dialog';
|
||||
import { CategoryIcon } from '@/components/shared/category-combobox';
|
||||
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||
import { CategorizerCard } from '@/components/transactions/categorizer-card';
|
||||
import { CategorizerCommand } from '@/components/transactions/categorizer-command';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { decrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { evaluateRules } from '@/lib/rule-engine';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import { useCategorizeTransactions } from '@/hooks/use-categorize-transactions';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type AutomationRule } from '@/types/automation-rule';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
import {
|
||||
type DecryptedTransaction,
|
||||
type Transaction,
|
||||
} from '@/types/transaction';
|
||||
import { formatDateLong } from '@/utils/date';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type Transaction } from '@/types/transaction';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
CheckCircle2,
|
||||
PartyPopper,
|
||||
Settings2,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useWebHaptics } from 'web-haptics/react';
|
||||
|
||||
const CATEGORY_USAGE_KEY = 'category-usage-order';
|
||||
|
||||
interface Props {
|
||||
categories: Category[];
|
||||
accounts: Account[];
|
||||
|
|
@ -54,400 +29,64 @@ interface Props {
|
|||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
type AnimationState = 'idle' | 'exiting' | 'entering' | 'success';
|
||||
|
||||
function getCategoryUsageOrder(): string[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(CATEGORY_USAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function updateCategoryUsageOrder(categoryId: string): void {
|
||||
try {
|
||||
const current = getCategoryUsageOrder();
|
||||
const filtered = current.filter((id) => id !== categoryId);
|
||||
const updated = [categoryId, ...filtered];
|
||||
localStorage.setItem(CATEGORY_USAGE_KEY, JSON.stringify(updated));
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
function sortCategoriesByUsage(
|
||||
categories: Category[],
|
||||
usageOrder: string[],
|
||||
): Category[] {
|
||||
const orderMap = new Map(usageOrder.map((id, index) => [id, index]));
|
||||
|
||||
return [...categories].sort((a, b) => {
|
||||
const aIndex = orderMap.get(a.id) ?? Infinity;
|
||||
const bIndex = orderMap.get(b.id) ?? Infinity;
|
||||
|
||||
if (aIndex === bIndex) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
}
|
||||
|
||||
export default function CategorizeTransactions({
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
transactions: initialTransactions,
|
||||
transactions,
|
||||
}: Props) {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const { automationRules: sharedAutomationRules } = usePage<{
|
||||
automationRules: AutomationRule[];
|
||||
}>().props;
|
||||
const locale = useLocale();
|
||||
const { trigger } = useWebHaptics();
|
||||
|
||||
const [uncategorizedTransactions, setUncategorizedTransactions] = useState<
|
||||
DecryptedTransaction[]
|
||||
>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [animationState, setAnimationState] =
|
||||
useState<AnimationState>('idle');
|
||||
const [categoryUsageOrder, setCategoryUsageOrder] = useState<string[]>([]);
|
||||
const [lastSelectedCategory, setLastSelectedCategory] =
|
||||
useState<Category | null>(null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [rulesDialogOpen, setRulesDialogOpen] = useState(false);
|
||||
const [encryptionKey, setEncryptionKey] = useState<CryptoKey | null>(null);
|
||||
const commandInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const automationRules = useMemo(
|
||||
() =>
|
||||
sharedAutomationRules.map((rule) => ({
|
||||
...rule,
|
||||
rules_json:
|
||||
typeof rule.rules_json === 'string'
|
||||
? JSON.parse(rule.rules_json)
|
||||
: rule.rules_json,
|
||||
})) as AutomationRule[],
|
||||
[sharedAutomationRules],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCategoryUsageOrder(getCategoryUsageOrder());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && animationState === 'idle') {
|
||||
commandInputRef.current?.focus();
|
||||
}
|
||||
}, [isLoading, animationState, currentIndex]);
|
||||
|
||||
// Decrypt transactions received from the backend and build the initial list
|
||||
useEffect(() => {
|
||||
async function decryptTransactions() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const accountsMap = new Map(
|
||||
accounts.map((account) => [account.id, account]),
|
||||
);
|
||||
const banksMap = new Map(banks.map((bank) => [bank.id, bank]));
|
||||
|
||||
const keyString = getStoredKey();
|
||||
let key: CryptoKey | null = null;
|
||||
|
||||
if (keyString && isKeySet) {
|
||||
try {
|
||||
key = await importKey(keyString);
|
||||
setEncryptionKey(key);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to import encryption key:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const decrypted = await Promise.all(
|
||||
initialTransactions.map(async (transaction) => {
|
||||
try {
|
||||
let decryptedDescription = '';
|
||||
let decryptedNotes: string | null = null;
|
||||
|
||||
if (!transaction.description_iv) {
|
||||
decryptedDescription = transaction.description;
|
||||
decryptedNotes = transaction.notes || null;
|
||||
} else if (key) {
|
||||
try {
|
||||
decryptedDescription = await decrypt(
|
||||
transaction.description,
|
||||
key,
|
||||
transaction.description_iv,
|
||||
);
|
||||
|
||||
if (
|
||||
transaction.notes &&
|
||||
transaction.notes_iv
|
||||
) {
|
||||
decryptedNotes = await decrypt(
|
||||
transaction.notes,
|
||||
key,
|
||||
transaction.notes_iv,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to decrypt transaction:',
|
||||
transaction.id,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const account = accountsMap.get(
|
||||
transaction.account_id,
|
||||
);
|
||||
const bank = account?.bank?.id
|
||||
? banksMap.get(account.bank.id)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...transaction,
|
||||
decryptedDescription,
|
||||
decryptedNotes,
|
||||
account,
|
||||
category: null,
|
||||
bank,
|
||||
} as DecryptedTransaction;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to process transaction:',
|
||||
transaction.id,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const validTransactions = decrypted.filter(
|
||||
(transaction): transaction is DecryptedTransaction =>
|
||||
transaction !== null,
|
||||
);
|
||||
|
||||
validTransactions.sort((a, b) => {
|
||||
const dateA = parseISO(a.transaction_date).getTime();
|
||||
const dateB = parseISO(b.transaction_date).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
setUncategorizedTransactions(validTransactions);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to load uncategorized transactions:',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
decryptTransactions();
|
||||
}, [initialTransactions, accounts, banks, isKeySet]);
|
||||
|
||||
const currentTransaction = uncategorizedTransactions[currentIndex];
|
||||
const remainingCount = uncategorizedTransactions.length - currentIndex;
|
||||
const isComplete = currentIndex >= uncategorizedTransactions.length;
|
||||
|
||||
const sortedCategories = useMemo(() => {
|
||||
return sortCategoriesByUsage(categories, categoryUsageOrder);
|
||||
}, [categories, categoryUsageOrder]);
|
||||
|
||||
const applyAutomationRulesToQueue = useCallback(async () => {
|
||||
if (
|
||||
!encryptionKey ||
|
||||
!automationRules.length ||
|
||||
uncategorizedTransactions.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingTransactions =
|
||||
uncategorizedTransactions.slice(currentIndex);
|
||||
let categorizedCount = 0;
|
||||
const newUncategorizedList: DecryptedTransaction[] = [
|
||||
...uncategorizedTransactions.slice(0, currentIndex),
|
||||
];
|
||||
|
||||
for (const transaction of remainingTransactions) {
|
||||
const result = await evaluateRules(
|
||||
transaction,
|
||||
automationRules,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
encryptionKey,
|
||||
);
|
||||
|
||||
if (result?.categoryId) {
|
||||
try {
|
||||
await transactionSyncService.update(transaction.id, {
|
||||
category_id: result.categoryId,
|
||||
});
|
||||
categorizedCount++;
|
||||
|
||||
const matchedCategory = categories.find(
|
||||
(c) => c.id === result.categoryId,
|
||||
);
|
||||
if (matchedCategory) {
|
||||
updateCategoryUsageOrder(matchedCategory.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to apply automation rule to transaction:',
|
||||
error,
|
||||
);
|
||||
newUncategorizedList.push(transaction);
|
||||
}
|
||||
} else {
|
||||
newUncategorizedList.push(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
if (categorizedCount > 0) {
|
||||
setCategoryUsageOrder(getCategoryUsageOrder());
|
||||
setUncategorizedTransactions(newUncategorizedList);
|
||||
setCurrentIndex(
|
||||
Math.min(currentIndex, newUncategorizedList.length),
|
||||
);
|
||||
}
|
||||
|
||||
return categorizedCount;
|
||||
}, [
|
||||
encryptionKey,
|
||||
automationRules,
|
||||
const {
|
||||
isLoading,
|
||||
isComplete,
|
||||
uncategorizedTransactions,
|
||||
currentTransaction,
|
||||
currentIndex,
|
||||
remainingCount,
|
||||
animationState,
|
||||
lastSelectedCategory,
|
||||
sortedCategories,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
rulesDialogOpen,
|
||||
setRulesDialogOpen,
|
||||
handleCategorySelect,
|
||||
handleSkip,
|
||||
handlePrevious,
|
||||
handleRulesDialogClose,
|
||||
commandInputRef,
|
||||
} = useCategorizeTransactions({
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
]);
|
||||
transactions,
|
||||
});
|
||||
|
||||
const handleRulesDialogClose = useCallback(
|
||||
async (open: boolean) => {
|
||||
setRulesDialogOpen(open);
|
||||
const backHref = categorizeRoute.url()?.replace('/categorize', '') ?? '';
|
||||
|
||||
if (!open) {
|
||||
await applyAutomationRulesToQueue();
|
||||
commandInputRef.current?.focus();
|
||||
}
|
||||
},
|
||||
[applyAutomationRulesToQueue],
|
||||
);
|
||||
|
||||
const handleCategorySelect = useCallback(
|
||||
async (category: Category) => {
|
||||
if (!currentTransaction || animationState !== 'idle') return;
|
||||
|
||||
setLastSelectedCategory(category);
|
||||
setAnimationState('exiting');
|
||||
|
||||
try {
|
||||
await transactionSyncService.update(currentTransaction.id, {
|
||||
category_id: category.id,
|
||||
});
|
||||
|
||||
updateCategoryUsageOrder(category.id);
|
||||
setCategoryUsageOrder(getCategoryUsageOrder());
|
||||
} catch (error) {
|
||||
console.error('Failed to update transaction:', error);
|
||||
setAnimationState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setAnimationState('success');
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentIndex((prev) => prev + 1);
|
||||
setAnimationState('entering');
|
||||
setSearchValue('');
|
||||
|
||||
setTimeout(() => {
|
||||
setAnimationState('idle');
|
||||
setLastSelectedCategory(null);
|
||||
commandInputRef.current?.focus();
|
||||
}, 300);
|
||||
}, 400);
|
||||
}, 300);
|
||||
},
|
||||
[currentTransaction, animationState],
|
||||
);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
if (animationState !== 'idle') return;
|
||||
|
||||
setAnimationState('exiting');
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentIndex((prev) => prev + 1);
|
||||
setAnimationState('entering');
|
||||
|
||||
setTimeout(() => {
|
||||
setAnimationState('idle');
|
||||
}, 300);
|
||||
}, 300);
|
||||
}, [animationState]);
|
||||
|
||||
const handlePrevious = useCallback(() => {
|
||||
if (animationState !== 'idle' || currentIndex === 0) return;
|
||||
|
||||
setAnimationState('exiting');
|
||||
|
||||
setTimeout(() => {
|
||||
setCurrentIndex((prev) => prev - 1);
|
||||
setAnimationState('entering');
|
||||
|
||||
setTimeout(() => {
|
||||
setAnimationState('idle');
|
||||
}, 300);
|
||||
}, 300);
|
||||
}, [animationState, currentIndex]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ctrl + R for rules dialog
|
||||
if (e.ctrlKey && e.key === 'r') {
|
||||
e.preventDefault();
|
||||
setRulesDialogOpen(true);
|
||||
}
|
||||
// Ctrl + N for skip (next)
|
||||
if (e.ctrlKey && e.key === 'n') {
|
||||
e.preventDefault();
|
||||
if (animationState === 'idle' && currentTransaction) {
|
||||
handleSkip();
|
||||
}
|
||||
}
|
||||
// Ctrl + B for previous (back)
|
||||
if (e.ctrlKey && e.key === 'b') {
|
||||
e.preventDefault();
|
||||
if (animationState === 'idle' && currentIndex > 0) {
|
||||
handlePrevious();
|
||||
}
|
||||
}
|
||||
// Escape to go back to transactions
|
||||
if (e.key === 'Escape' && !rulesDialogOpen) {
|
||||
e.preventDefault();
|
||||
const transactionsUrl = categorizeRoute
|
||||
.url()
|
||||
?.replace('/categorize', '');
|
||||
if (transactionsUrl) {
|
||||
router.visit(transactionsUrl);
|
||||
if (backHref) {
|
||||
router.visit(backHref);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -461,6 +100,8 @@ export default function CategorizeTransactions({
|
|||
handlePrevious,
|
||||
currentIndex,
|
||||
rulesDialogOpen,
|
||||
setRulesDialogOpen,
|
||||
backHref,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -504,12 +145,7 @@ export default function CategorizeTransactions({
|
|||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={categorizeRoute
|
||||
.url()
|
||||
?.replace('/categorize', '')}
|
||||
onClick={() => trigger('light')}
|
||||
>
|
||||
<Link href={backHref} onClick={() => trigger('light')}>
|
||||
<Button size="lg" className="mt-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Transactions')}
|
||||
|
|
@ -517,6 +153,18 @@ export default function CategorizeTransactions({
|
|||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes bounce-in {
|
||||
0% { opacity: 0; transform: scale(0.3); }
|
||||
50% { transform: scale(1.05); }
|
||||
70% { transform: scale(0.9); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.animate-bounce-in {
|
||||
animation: bounce-in 0.6s ease-out forwards;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -540,12 +188,7 @@ export default function CategorizeTransactions({
|
|||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={categorizeRoute
|
||||
.url()
|
||||
?.replace('/categorize', '')}
|
||||
onClick={() => trigger('light')}
|
||||
>
|
||||
<Link href={backHref} onClick={() => trigger('light')}>
|
||||
<Button>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Transactions')}
|
||||
|
|
@ -564,7 +207,7 @@ export default function CategorizeTransactions({
|
|||
<div className="flex min-h-screen flex-col bg-white dark:bg-zinc-950">
|
||||
<header className="flex items-center justify-between gap-6 px-4 py-3 dark:border-zinc-800">
|
||||
<Link
|
||||
href={categorizeRoute.url()?.replace('/categorize', '')}
|
||||
href={backHref}
|
||||
onClick={() => trigger('light')}
|
||||
className="flex w-fit flex-1 items-center gap-2 text-sm text-zinc-600 opacity-50 transition-all duration-200 hover:text-zinc-900 hover:opacity-100 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||
>
|
||||
|
|
@ -583,7 +226,6 @@ export default function CategorizeTransactions({
|
|||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{__('Rules')}
|
||||
|
||||
<Kbd>{__('Ctrl+R')}</Kbd>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -628,215 +270,20 @@ export default function CategorizeTransactions({
|
|||
|
||||
<main className="flex flex-1 flex-col items-center justify-start p-4 sm:justify-center">
|
||||
<div className="w-full max-w-xl space-y-8">
|
||||
<div className="relative">
|
||||
{animationState === 'success' &&
|
||||
lastSelectedCategory && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-success-pop flex flex-col items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-16 w-16 items-center justify-center rounded-full',
|
||||
getCategoryColorClasses(
|
||||
lastSelectedCategory.color,
|
||||
).bg,
|
||||
)}
|
||||
>
|
||||
<CheckCircle2
|
||||
className={cn(
|
||||
'h-8 w-8',
|
||||
getCategoryColorClasses(
|
||||
lastSelectedCategory.color,
|
||||
).text,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-zinc-600 dark:text-zinc-400">
|
||||
{lastSelectedCategory.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{animationState !== 'success' &&
|
||||
currentTransaction && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl shadow-zinc-200/50 transition-all duration-300 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-zinc-900/50',
|
||||
animationState === 'exiting' &&
|
||||
'translate-y-[-20px] scale-95 opacity-0',
|
||||
animationState === 'entering' &&
|
||||
'animate-card-enter',
|
||||
animationState === 'idle' &&
|
||||
'translate-y-0 scale-100 opacity-100',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{formatDateLong(
|
||||
currentTransaction.transaction_date,
|
||||
locale,
|
||||
)}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl whitespace-pre-wrap text-zinc-900 dark:text-zinc-100">
|
||||
{currentTransaction.decryptedDescription ||
|
||||
'Encrypted'}
|
||||
</h2>
|
||||
|
||||
{currentTransaction.account && (
|
||||
<div className="flex items-center gap-2">
|
||||
{currentTransaction
|
||||
.bank
|
||||
?.logo && (
|
||||
<img
|
||||
src={
|
||||
currentTransaction
|
||||
.bank
|
||||
.logo
|
||||
}
|
||||
alt={
|
||||
currentTransaction
|
||||
.bank
|
||||
.name
|
||||
}
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
)}
|
||||
<AccountName
|
||||
account={
|
||||
currentTransaction.account
|
||||
}
|
||||
className="text-sm text-zinc-600 dark:text-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentTransaction.decryptedNotes && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{
|
||||
currentTransaction.decryptedNotes
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 font-mono text-xl text-muted-foreground">
|
||||
<AmountDisplay
|
||||
amountInCents={
|
||||
currentTransaction.amount
|
||||
}
|
||||
currencyCode={
|
||||
currentTransaction.currency_code
|
||||
}
|
||||
variant="positive-highlight"
|
||||
highlightPositive={
|
||||
currentTransaction.amount >=
|
||||
0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{animationState !== 'success' && currentTransaction && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-4 px-6 pt-2 transition-all duration-300',
|
||||
animationState === 'exiting' &&
|
||||
'translate-y-[-10px] opacity-0',
|
||||
animationState === 'entering' &&
|
||||
'animate-command-enter',
|
||||
animationState === 'idle' &&
|
||||
'translate-y-0 opacity-100',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-medium">
|
||||
{__('Assign a new category')}
|
||||
</h3>
|
||||
<p className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
{__('Search, move')}{' '}
|
||||
<Kbd>
|
||||
<ArrowUp className="size-3" />
|
||||
</Kbd>
|
||||
<Kbd>
|
||||
<ArrowDown className="size-3" />
|
||||
</Kbd>
|
||||
{__(', and press')}
|
||||
<Kbd>⏎</Kbd>
|
||||
</p>
|
||||
</div>
|
||||
<Command
|
||||
className="rounded-xl border border-zinc-200 bg-white shadow-lg dark:border-zinc-800 dark:bg-zinc-900"
|
||||
shouldFilter={true}
|
||||
>
|
||||
<CommandInput
|
||||
ref={commandInputRef}
|
||||
placeholder={__('Search categories...')}
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
disabled={animationState !== 'idle'}
|
||||
/>
|
||||
|
||||
<CommandList className="max-h-64">
|
||||
<CommandEmpty>
|
||||
{__('No categories found.')}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sortedCategories.map(
|
||||
(category) => {
|
||||
const colorClasses =
|
||||
getCategoryColorClasses(
|
||||
category.color,
|
||||
);
|
||||
return (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
value={
|
||||
category.name
|
||||
}
|
||||
onSelect={() =>
|
||||
handleCategorySelect(
|
||||
category,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
animationState !==
|
||||
'idle'
|
||||
}
|
||||
className="group cursor-pointer gap-3 p-2"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded-full transition-transform duration-200 group-data-[selected=true]:scale-110',
|
||||
colorClasses.bg,
|
||||
)}
|
||||
>
|
||||
<CategoryIcon
|
||||
category={
|
||||
category
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<span className="flex-1 truncate font-medium">
|
||||
{category.name}
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
)}
|
||||
<CategorizerCard
|
||||
transaction={currentTransaction}
|
||||
animationState={animationState}
|
||||
lastSelectedCategory={lastSelectedCategory}
|
||||
/>
|
||||
<CategorizerCommand
|
||||
sortedCategories={sortedCategories}
|
||||
animationState={animationState}
|
||||
currentTransaction={currentTransaction}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
onCategorySelect={handleCategorySelect}
|
||||
commandInputRef={commandInputRef}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -845,78 +292,6 @@ export default function CategorizeTransactions({
|
|||
open={rulesDialogOpen}
|
||||
onOpenChange={handleRulesDialogClose}
|
||||
/>
|
||||
|
||||
<style>{`
|
||||
@keyframes card-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes command-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes success-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.3);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-card-enter {
|
||||
animation: card-enter 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-command-enter {
|
||||
animation: command-enter 0.3s ease-out 0.1s forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.animate-success-pop {
|
||||
animation: success-pop 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounce-in 0.6s ease-out forwards;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,11 +50,14 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
|
||||
Route::middleware(['onboarded'])->group(function () {
|
||||
Route::get('onboarding', [OnboardingController::class, 'index'])->name('onboarding');
|
||||
Route::get('onboarding/sync-status', [OnboardingController::class, 'syncStatus'])->name('onboarding.sync-status');
|
||||
Route::post('onboarding/complete', [OnboardingController::class, 'complete'])->name('onboarding.complete');
|
||||
});
|
||||
|
||||
// Accessible during onboarding for transaction import
|
||||
// Accessible during onboarding for transaction import and categorization
|
||||
Route::post('transactions', [TransactionController::class, 'store'])->name('transactions.store');
|
||||
Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('transactions.bulk-update');
|
||||
Route::patch('transactions/{transaction}', [TransactionController::class, 'update'])->name('transactions.update');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
|
||||
|
|
@ -66,10 +69,8 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(functi
|
|||
|
||||
Route::get('transactions', [TransactionController::class, 'index'])->name('transactions.index');
|
||||
Route::get('transactions/categorize', [TransactionController::class, 'categorize'])->name('transactions.categorize');
|
||||
Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('transactions.bulk-update');
|
||||
Route::post('transactions/re-evaluate-rules', [ReEvaluateTransactionRulesController::class, 'bulk'])->name('transactions.re-evaluate-rules.bulk');
|
||||
Route::get('transactions/re-evaluate-rules/status/{jobId}', [ReEvaluateTransactionRulesController::class, 'status'])->name('transactions.re-evaluate-rules.status');
|
||||
Route::patch('transactions/{transaction}', [TransactionController::class, 'update'])->name('transactions.update');
|
||||
Route::delete('transactions/{transaction}', [TransactionController::class, 'destroy'])->name('transactions.destroy');
|
||||
Route::post('transactions/{transaction}/re-evaluate-rules', [ReEvaluateTransactionRulesController::class, 'single'])->name('transactions.re-evaluate-rules.single');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -148,8 +148,8 @@ it('allows continuing with existing accounts', function () {
|
|||
// Click Continue to proceed
|
||||
->click('Continue')
|
||||
->wait(2)
|
||||
// Should go to import transactions (since checking account needs transactions)
|
||||
->assertSee('Import Your Transactions')
|
||||
// Should go to category types (existing accounts no longer trigger import)
|
||||
->assertSee('Understanding Categories')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
|
|
@ -180,9 +180,8 @@ it('shows import transactions step after account creation', function () {
|
|||
->wait(1)
|
||||
->click('Continue')
|
||||
->wait(2)
|
||||
// Should show import transactions step
|
||||
->assertSee('Import Your Transactions')
|
||||
->assertSee('Import Transactions')
|
||||
// Should go to category types (existing accounts no longer trigger import)
|
||||
->assertSee('Understanding Categories')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
|
|
@ -296,7 +295,12 @@ it('completes entire onboarding flow with account creation, transaction import,
|
|||
->click('Import 5 Transactions')
|
||||
->wait(15);
|
||||
|
||||
// After import completes, drawer closes and transitions to Category Types
|
||||
// After import completes, back to create-account step in list mode
|
||||
$page->assertSee('My Checking Account')
|
||||
->click('Continue')
|
||||
->wait(1);
|
||||
|
||||
// Category Types
|
||||
$page->assertSee('Understanding Categories')
|
||||
->click('Continue')
|
||||
->wait(1);
|
||||
|
|
@ -306,10 +310,10 @@ it('completes entire onboarding flow with account creation, transaction import,
|
|||
->click('Continue')
|
||||
->wait(1);
|
||||
|
||||
// More Accounts - verify account is listed, then finish
|
||||
$page->assertSee('Great Progress!')
|
||||
->assertSee('My Checking Account')
|
||||
->click('Finish Setup')
|
||||
// Categorize Transactions - transactions prop was loaded at page start (before import),
|
||||
// so no uncategorized transactions are available and Continue is immediately enabled
|
||||
$page->assertSee('No Uncategorized Transactions')
|
||||
->click('Continue')
|
||||
->wait(1);
|
||||
|
||||
// Complete step
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
|
||||
it('returns categories and transactions props on onboarding index', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => null]);
|
||||
$bank = Bank::factory()->create();
|
||||
$account = Account::factory()->create(['user_id' => $user->id, 'bank_id' => $bank->id]);
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
// One uncategorized and one categorized transaction
|
||||
$uncategorized = Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => null,
|
||||
]);
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get('/onboarding');
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('onboarding/index')
|
||||
->has('categories', 1)
|
||||
->has('transactions', 1)
|
||||
->where('transactions.0.id', $uncategorized->id)
|
||||
);
|
||||
});
|
||||
|
||||
it('returns only uncategorized transactions in the transactions prop', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => null]);
|
||||
$bank = Bank::factory()->create();
|
||||
$account = Account::factory()->create(['user_id' => $user->id, 'bank_id' => $bank->id]);
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
Transaction::factory()->plaintext()->count(3)->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => null,
|
||||
]);
|
||||
Transaction::factory()->plaintext()->count(2)->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get('/onboarding');
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->has('transactions', 3)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not return transactions belonging to other users', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => null]);
|
||||
$other = User::factory()->create(['onboarded_at' => null]);
|
||||
$bank = Bank::factory()->create();
|
||||
$account = Account::factory()->create(['user_id' => $other->id, 'bank_id' => $bank->id]);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $other->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => null,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get('/onboarding');
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->has('transactions', 0)
|
||||
);
|
||||
});
|
||||
|
||||
it('returns banks and accounts props on onboarding index', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => null]);
|
||||
$globalBank = Bank::factory()->create(['user_id' => null]);
|
||||
$userBank = Bank::factory()->create(['user_id' => $user->id]);
|
||||
$otherBank = Bank::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
$response = $this->actingAs($user)->get('/onboarding');
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('onboarding/index')
|
||||
->has('banks', 2) // global + user's own bank
|
||||
->has('accounts')
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Models\User;
|
||||
|
||||
it('returns pending false when user has no banking connections', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => null]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson('/onboarding/sync-status')
|
||||
->assertOk()
|
||||
->assertJson(['pending' => false]);
|
||||
});
|
||||
|
||||
it('returns pending false when all banking connections have been synced', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => null]);
|
||||
|
||||
BankingConnection::factory()->for($user)->create([
|
||||
'status' => BankingConnectionStatus::Active,
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson('/onboarding/sync-status')
|
||||
->assertOk()
|
||||
->assertJson(['pending' => false]);
|
||||
});
|
||||
|
||||
it('returns pending true when an active connection has not been synced yet', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => null]);
|
||||
|
||||
BankingConnection::factory()->for($user)->create([
|
||||
'status' => BankingConnectionStatus::Active,
|
||||
'last_synced_at' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson('/onboarding/sync-status')
|
||||
->assertOk()
|
||||
->assertJson(['pending' => true]);
|
||||
});
|
||||
|
||||
it('returns pending false when unsynced connection has an error status', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => null]);
|
||||
|
||||
BankingConnection::factory()->for($user)->error()->create([
|
||||
'last_synced_at' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson('/onboarding/sync-status')
|
||||
->assertOk()
|
||||
->assertJson(['pending' => false]);
|
||||
});
|
||||
|
||||
it('returns pending false when unsynced connection is revoked', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => null]);
|
||||
|
||||
BankingConnection::factory()->for($user)->revoked()->create([
|
||||
'last_synced_at' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson('/onboarding/sync-status')
|
||||
->assertOk()
|
||||
->assertJson(['pending' => false]);
|
||||
});
|
||||
|
||||
it('requires authentication', function () {
|
||||
$this->getJson('/onboarding/sync-status')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
it('only considers the authenticated users connections', function () {
|
||||
$user = User::factory()->create(['onboarded_at' => null]);
|
||||
$other = User::factory()->create(['onboarded_at' => null]);
|
||||
|
||||
// Other user has a pending sync — should not affect our user
|
||||
BankingConnection::factory()->for($other)->create([
|
||||
'status' => BankingConnectionStatus::Active,
|
||||
'last_synced_at' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson('/onboarding/sync-status')
|
||||
->assertOk()
|
||||
->assertJson(['pending' => false]);
|
||||
});
|
||||
Loading…
Reference in New Issue