362 lines
14 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|