whisper-money/resources/js/components/accounts/create-account-dialog.tsx

362 lines
14 KiB
TypeScript

import { store } from '@/actions/App/Http/Controllers/Settings/AccountController';
import { store as storeBank } from '@/actions/App/Http/Controllers/Settings/BankController';
import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer';
import { ConnectAccountDialog } from '@/components/open-banking/connect-account-dialog';
import { UpgradeDialog } from '@/components/subscription/upgrade-dialog';
import { Button } from '@/components/ui/button';
import { CreateButton } from '@/components/ui/create-button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { getCsrfToken } from '@/lib/csrf';
import { SharedData } from '@/types';
import { Account } from '@/types/account';
import type { BankingConnection } from '@/types/banking';
import { __ } from '@/utils/i18n';
import { router, usePage } from '@inertiajs/react';
import { Link2, PenLine } from 'lucide-react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { AccountForm, AccountFormData } from './account-form';
type Mode = 'choice' | 'manual';
export function CreateAccountDialog({
onSuccess,
trigger,
}: {
onSuccess?: () => void;
trigger?: React.ReactNode;
}) {
const {
auth,
subscriptionsEnabled,
accounts: sharedAccounts,
bankingConnections: sharedConnections,
} = usePage<SharedData>().props;
const isFreePlan = subscriptionsEnabled && !auth?.hasProPlan;
const sharedAccountsList = useMemo(
() => (sharedAccounts as Account[]) || [],
[sharedAccounts],
);
const connections = useMemo(
() => (sharedConnections as BankingConnection[]) || [],
[sharedConnections],
);
const availableLoanAccounts = useMemo(
() => sharedAccountsList.filter((a) => a.type === 'loan'),
[sharedAccountsList],
);
const isFirstAccount = sharedAccountsList.length === 0;
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<Mode>('choice');
const [isSubmitting, setIsSubmitting] = useState(false);
const [connectDialogOpen, setConnectDialogOpen] = useState(false);
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
const [integrationDrawerOpen, setIntegrationDrawerOpen] = useState(false);
const formDataRef = useRef<AccountFormData>({
displayName: '',
bankId: null,
type: null,
currencyCode: null,
customBank: null,
balance: null,
realEstate: null,
loan: null,
});
const handleFormChange = useCallback((data: AccountFormData) => {
formDataRef.current = data;
}, []);
function handleOpenChange(newOpen: boolean) {
setOpen(newOpen);
if (!newOpen) {
setMode('choice');
}
}
async function createBankAndGetId(): Promise<string | null> {
const customBank = formDataRef.current.customBank;
if (!customBank) return null;
const formData = new FormData();
formData.append('name', customBank.name);
if (customBank.logo) {
formData.append('logo', customBank.logo);
}
try {
const response = await fetch(storeBank.url(), {
method: 'POST',
body: formData,
headers: {
'X-XSRF-TOKEN': getCsrfToken(),
Accept: 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to create bank');
}
const data = await response.json();
return data.id;
} catch (err) {
console.error('Failed to create bank:', err);
throw err;
}
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const { displayName, bankId, type, currencyCode, customBank } =
formDataRef.current;
if (!type || !currencyCode) {
alert('Please fill in all required fields.');
return;
}
const isRealEstate = type === 'real_estate';
setIsSubmitting(true);
try {
let finalBankId: string | null = null;
if (!isRealEstate) {
if (customBank) {
if (!customBank.name.trim()) {
alert('Please enter a bank name.');
setIsSubmitting(false);
return;
}
const createdBankId = await createBankAndGetId();
if (!createdBankId) {
throw new Error('Failed to create bank');
}
finalBankId = createdBankId;
} else {
if (!bankId) {
alert('Please select a bank.');
setIsSubmitting(false);
return;
}
finalBankId = String(bankId);
}
}
router.post(
store.url(),
{
name: displayName,
...(finalBankId ? { bank_id: finalBankId } : {}),
type: type,
currency_code: currencyCode,
...(formDataRef.current.balance
? { balance: formDataRef.current.balance }
: {}),
...(formDataRef.current.realEstate
? {
property_type:
formDataRef.current.realEstate.propertyType,
address:
formDataRef.current.realEstate.address ||
null,
purchase_price:
formDataRef.current.realEstate
.purchasePrice || null,
purchase_date:
formDataRef.current.realEstate.purchaseDate ||
null,
area_value:
formDataRef.current.realEstate.areaValue ||
null,
area_unit:
formDataRef.current.realEstate.areaUnit,
linked_loan_account_id:
formDataRef.current.realEstate
.linkedLoanAccountId,
notes:
formDataRef.current.realEstate.notes || null,
revaluation_percentage:
formDataRef.current.realEstate
.revaluationPercentage || null,
}
: {}),
...(formDataRef.current.loan
? {
annual_interest_rate:
formDataRef.current.loan.annualInterestRate ||
null,
loan_term_months:
formDataRef.current.loan.loanTermMonths ||
null,
loan_start_date:
formDataRef.current.loan.startDate || null,
original_amount:
formDataRef.current.loan.originalAmount ||
null,
linked_real_estate_account_id:
formDataRef.current.loan
.linkedRealEstateAccountId,
}
: {}),
},
{
onSuccess: () => {
handleOpenChange(false);
onSuccess?.();
},
onFinish: () => {
setIsSubmitting(false);
},
},
);
} catch (err) {
console.error('Submission failed:', err);
alert(
err instanceof Error
? err.message
: 'Failed to create account. Please try again.',
);
setIsSubmitting(false);
}
}
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{trigger ?? (
<CreateButton data-testid="open-create-account">
{__('Create Account')}
</CreateButton>
)}
</DialogTrigger>
<DialogContent hasKeyboard className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{__('Create Account')}</DialogTitle>
<DialogDescription>
{mode === 'choice'
? __(
'Add a bank account, loan, or property to your workspace.',
)
: __(
'Create a bank account, loan, or property to track it manually.',
)}
</DialogDescription>
</DialogHeader>
{mode === 'choice' && (
<div className="grid grid-cols-2 gap-3">
<button
type="button"
className="flex flex-col items-center gap-3 rounded-lg border p-3 pb-6 text-center transition-colors hover:bg-accent"
onClick={() => setMode('manual')}
>
<PenLine className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">
{__('Manual')}
</p>
<p className="text-xs text-balance text-muted-foreground">
{__(
'Create bank accounts, loans, or properties manually and import transactions.',
)}
</p>
</div>
</button>
<button
type="button"
className="flex flex-col items-center gap-3 rounded-lg border p-3 pb-6 text-center transition-colors hover:bg-accent"
onClick={() => {
if (isFreePlan) {
setUpgradeDialogOpen(true);
} else {
handleOpenChange(false);
setConnectDialogOpen(true);
}
}}
>
<Link2 className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">
{__('Connected')}
</p>
<p className="text-xs text-balance text-muted-foreground">
{__(
'Connect your bank and sync transactions automatically.',
)}
</p>
</div>
</button>
</div>
)}
{mode === 'manual' && (
<form onSubmit={handleSubmit} className="space-y-2">
<AccountForm
onChange={handleFormChange}
availableLoanAccounts={availableLoanAccounts}
usePrimaryCurrenciesOnly={isFirstAccount}
/>
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => {
setMode('choice');
}}
disabled={isSubmitting}
>
{__('Back')}
</Button>
<Button
type="submit"
disabled={isSubmitting}
data-testid="submit-account"
>
{isSubmitting
? __('Creating...')
: __('Create')}
</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
<ConnectAccountDialog
open={connectDialogOpen}
onOpenChange={setConnectDialogOpen}
connections={connections}
/>
<UpgradeDialog
open={upgradeDialogOpen}
onOpenChange={setUpgradeDialogOpen}
title={__('Connected accounts are a paid feature')}
description={__(
'Subscribe to a plan to link a bank account and keep it synced automatically.',
)}
source="accounts"
/>
<IntegrationRequestsDrawer
open={integrationDrawerOpen}
onOpenChange={setIntegrationDrawerOpen}
/>
</>
);
}