fix: billing portal error, onboarding wizard improvements, budget empty state
- Fix "Manage Billing" button error for trial orgs without Stripe customer; add fallback to retrieve customer from subscription, show helpful message for trial users, and surface real error messages in the UI - Add "Balance As-Of Date" field to onboarding wizard so opening balance journal entries use the correct statement date instead of today - Add "Total Unit Count" field to onboarding wizard assessment group step so cash flow projections work immediately - Remove broken budget upload step from onboarding wizard (was using legacy budgets endpoint); replace with guidance to use Budget Planning page - Replace bare "No budget plan lines" text with rich onboarding-style card featuring download template and upload CSV action buttons Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -74,9 +74,9 @@ export class AccountsService {
|
|||||||
|
|
||||||
// Create opening balance journal entry if initialBalance is provided and non-zero
|
// Create opening balance journal entry if initialBalance is provided and non-zero
|
||||||
if (dto.initialBalance && dto.initialBalance !== 0) {
|
if (dto.initialBalance && dto.initialBalance !== 0) {
|
||||||
const now = new Date();
|
const balanceDate = dto.initialBalanceDate ? new Date(dto.initialBalanceDate) : new Date();
|
||||||
const year = now.getFullYear();
|
const year = balanceDate.getFullYear();
|
||||||
const month = now.getMonth() + 1;
|
const month = balanceDate.getMonth() + 1;
|
||||||
|
|
||||||
// Find the current fiscal period
|
// Find the current fiscal period
|
||||||
const periods = await this.tenant.query(
|
const periods = await this.tenant.query(
|
||||||
@@ -111,12 +111,14 @@ export class AccountsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the journal entry
|
// Create the journal entry (use provided balance date or today)
|
||||||
|
const entryDate = dto.initialBalanceDate || new Date().toISOString().split('T')[0];
|
||||||
const jeInsert = await this.tenant.query(
|
const jeInsert = await this.tenant.query(
|
||||||
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
||||||
VALUES (CURRENT_DATE, $1, 'opening_balance', $2, true, NOW(), $3)
|
VALUES ($1::date, $2, 'opening_balance', $3, true, NOW(), $4)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[
|
[
|
||||||
|
entryDate,
|
||||||
`Opening balance for ${dto.name}`,
|
`Opening balance for ${dto.name}`,
|
||||||
fiscalPeriodId,
|
fiscalPeriodId,
|
||||||
'00000000-0000-0000-0000-000000000000',
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export class CreateAccountDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
initialBalance?: number;
|
initialBalance?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: 'ISO date string (YYYY-MM-DD) for when the initial balance was accurate' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
initialBalanceDate?: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false, description: 'Annual interest rate as a percentage' })
|
@ApiProperty({ required: false, description: 'Annual interest rate as a percentage' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
interestRate?: number;
|
interestRate?: number;
|
||||||
|
|||||||
@@ -279,18 +279,49 @@ export class BillingService {
|
|||||||
* Create a Stripe Customer Portal session for managing subscription.
|
* Create a Stripe Customer Portal session for managing subscription.
|
||||||
*/
|
*/
|
||||||
async createPortalSession(orgId: string): Promise<{ url: string }> {
|
async createPortalSession(orgId: string): Promise<{ url: string }> {
|
||||||
if (!this.stripe) throw new BadRequestException('Stripe not configured');
|
if (!this.stripe) throw new BadRequestException('Stripe is not configured');
|
||||||
|
|
||||||
const rows = await this.dataSource.query(
|
const rows = await this.dataSource.query(
|
||||||
`SELECT stripe_customer_id FROM shared.organizations WHERE id = $1`,
|
`SELECT stripe_customer_id, stripe_subscription_id, status
|
||||||
|
FROM shared.organizations WHERE id = $1`,
|
||||||
[orgId],
|
[orgId],
|
||||||
);
|
);
|
||||||
if (rows.length === 0 || !rows[0].stripe_customer_id) {
|
if (rows.length === 0) {
|
||||||
throw new BadRequestException('No Stripe customer found for this organization');
|
throw new BadRequestException('Organization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
let customerId = rows[0].stripe_customer_id;
|
||||||
|
|
||||||
|
// Fallback: if customer ID is missing but subscription exists, retrieve customer from subscription
|
||||||
|
if (!customerId && rows[0].stripe_subscription_id) {
|
||||||
|
try {
|
||||||
|
const sub = await this.stripe.subscriptions.retrieve(rows[0].stripe_subscription_id) as Stripe.Subscription;
|
||||||
|
customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id;
|
||||||
|
if (customerId) {
|
||||||
|
// Backfill the customer ID for future calls
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.organizations SET stripe_customer_id = $1 WHERE id = $2`,
|
||||||
|
[customerId, orgId],
|
||||||
|
);
|
||||||
|
this.logger.log(`Backfilled stripe_customer_id=${customerId} for org=${orgId}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to retrieve customer from subscription: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
const status = rows[0].status;
|
||||||
|
if (status === 'trial') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Billing portal is not available during your free trial. Add a payment method when your trial ends to manage your subscription.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new BadRequestException('No Stripe customer found for this organization. Please contact support.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await this.stripe.billingPortal.sessions.create({
|
const session = await this.stripe.billingPortal.sessions.create({
|
||||||
customer: rows[0].stripe_customer_id,
|
customer: customerId,
|
||||||
return_url: `${this.getAppUrl()}/settings`,
|
return_url: `${this.getAppUrl()}/settings`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -311,10 +342,11 @@ export class BillingService {
|
|||||||
trialEndsAt: string | null;
|
trialEndsAt: string | null;
|
||||||
currentPeriodEnd: string | null;
|
currentPeriodEnd: string | null;
|
||||||
cancelAtPeriodEnd: boolean;
|
cancelAtPeriodEnd: boolean;
|
||||||
|
hasStripeCustomer: boolean;
|
||||||
}> {
|
}> {
|
||||||
const rows = await this.dataSource.query(
|
const rows = await this.dataSource.query(
|
||||||
`SELECT plan_level, billing_interval, status, collection_method,
|
`SELECT plan_level, billing_interval, status, collection_method,
|
||||||
trial_ends_at, stripe_subscription_id
|
trial_ends_at, stripe_subscription_id, stripe_customer_id
|
||||||
FROM shared.organizations WHERE id = $1`,
|
FROM shared.organizations WHERE id = $1`,
|
||||||
[orgId],
|
[orgId],
|
||||||
);
|
);
|
||||||
@@ -351,6 +383,7 @@ export class BillingService {
|
|||||||
trialEndsAt: org.trial_ends_at ? new Date(org.trial_ends_at).toISOString() : null,
|
trialEndsAt: org.trial_ends_at ? new Date(org.trial_ends_at).toISOString() : null,
|
||||||
currentPeriodEnd,
|
currentPeriodEnd,
|
||||||
cancelAtPeriodEnd,
|
cancelAtPeriodEnd,
|
||||||
|
hasStripeCustomer: !!org.stripe_customer_id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea,
|
Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea,
|
||||||
Select, Stack, Text, Title, Alert, ActionIcon, Table, FileInput,
|
Select, Stack, Text, Title, Alert, ActionIcon, Table,
|
||||||
Card, ThemeIcon, Divider, Loader, Badge, SimpleGrid, Box,
|
Card, ThemeIcon, Divider, Badge, SimpleGrid, Box,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
|
import { DateInput } from '@mantine/dates';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
IconBuildingBank, IconUsers, IconFileSpreadsheet,
|
IconBuildingBank, IconUsers,
|
||||||
IconPlus, IconTrash, IconDownload, IconCheck, IconRocket,
|
IconPlus, IconTrash, IconCheck, IconRocket,
|
||||||
IconAlertCircle,
|
IconAlertCircle, IconFileSpreadsheet,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
@@ -24,27 +25,6 @@ interface UnitRow {
|
|||||||
ownerEmail: string;
|
ownerEmail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CSV Parsing (reused from BudgetsPage pattern) ──
|
|
||||||
function parseCSV(text: string): Record<string, string>[] {
|
|
||||||
const lines = text.split('\n').filter((l) => l.trim());
|
|
||||||
if (lines.length < 2) return [];
|
|
||||||
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
|
|
||||||
return lines.slice(1).map((line) => {
|
|
||||||
const values: string[] = [];
|
|
||||||
let current = '';
|
|
||||||
let inQuotes = false;
|
|
||||||
for (const char of line) {
|
|
||||||
if (char === '"') { inQuotes = !inQuotes; }
|
|
||||||
else if (char === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
|
|
||||||
else { current += char; }
|
|
||||||
}
|
|
||||||
values.push(current.trim());
|
|
||||||
const row: Record<string, string> = {};
|
|
||||||
headers.forEach((h, i) => { row[h] = values[i] || ''; });
|
|
||||||
return row;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
|
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -57,22 +37,17 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
const [accountNumber, setAccountNumber] = useState('1000');
|
const [accountNumber, setAccountNumber] = useState('1000');
|
||||||
const [accountDescription, setAccountDescription] = useState('');
|
const [accountDescription, setAccountDescription] = useState('');
|
||||||
const [initialBalance, setInitialBalance] = useState<number | string>(0);
|
const [initialBalance, setInitialBalance] = useState<number | string>(0);
|
||||||
|
const [balanceDate, setBalanceDate] = useState<Date | null>(new Date());
|
||||||
|
|
||||||
// ── Step 2: Assessment Group State ──
|
// ── Step 2: Assessment Group State ──
|
||||||
const [groupCreated, setGroupCreated] = useState(false);
|
const [groupCreated, setGroupCreated] = useState(false);
|
||||||
const [groupName, setGroupName] = useState('Standard Assessment');
|
const [groupName, setGroupName] = useState('Standard Assessment');
|
||||||
const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
|
const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
|
||||||
const [frequency, setFrequency] = useState('monthly');
|
const [frequency, setFrequency] = useState('monthly');
|
||||||
|
const [unitCount, setUnitCount] = useState<number | string>(0);
|
||||||
const [units, setUnits] = useState<UnitRow[]>([]);
|
const [units, setUnits] = useState<UnitRow[]>([]);
|
||||||
const [unitsCreated, setUnitsCreated] = useState(false);
|
const [unitsCreated, setUnitsCreated] = useState(false);
|
||||||
|
|
||||||
// ── Step 3: Budget State ──
|
|
||||||
const [budgetFile, setBudgetFile] = useState<File | null>(null);
|
|
||||||
const [budgetUploaded, setBudgetUploaded] = useState(false);
|
|
||||||
const [budgetImportResult, setBudgetImportResult] = useState<any>(null);
|
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
// ── Step 1: Create Account ──
|
// ── Step 1: Create Account ──
|
||||||
const handleCreateAccount = async () => {
|
const handleCreateAccount = async () => {
|
||||||
if (!accountName.trim()) {
|
if (!accountName.trim()) {
|
||||||
@@ -99,6 +74,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
accountType: 'asset',
|
accountType: 'asset',
|
||||||
fundType: 'operating',
|
fundType: 'operating',
|
||||||
initialBalance: balance,
|
initialBalance: balance,
|
||||||
|
initialBalanceDate: balanceDate ? balanceDate.toISOString().split('T')[0] : undefined,
|
||||||
});
|
});
|
||||||
setAccountCreated(true);
|
setAccountCreated(true);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -126,6 +102,8 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const count = typeof unitCount === 'string' ? parseInt(unitCount) : unitCount;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -133,6 +111,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
name: groupName.trim(),
|
name: groupName.trim(),
|
||||||
regularAssessment: assessment,
|
regularAssessment: assessment,
|
||||||
frequency,
|
frequency,
|
||||||
|
unitCount: isNaN(count) ? 0 : count,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
});
|
});
|
||||||
setGroupCreated(true);
|
setGroupCreated(true);
|
||||||
@@ -175,62 +154,6 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Step 3: Budget Import ──
|
|
||||||
const handleDownloadTemplate = async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/budgets/${currentYear}/template`, {
|
|
||||||
responseType: 'blob',
|
|
||||||
});
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.setAttribute('download', `budget_template_${currentYear}.csv`);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
} catch {
|
|
||||||
notifications.show({
|
|
||||||
title: 'Error',
|
|
||||||
message: 'Failed to download template',
|
|
||||||
color: 'red',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUploadBudget = async () => {
|
|
||||||
if (!budgetFile) {
|
|
||||||
setError('Please select a CSV file');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const text = await budgetFile.text();
|
|
||||||
const rows = parseCSV(text);
|
|
||||||
if (rows.length === 0) {
|
|
||||||
setError('CSV file appears to be empty or invalid');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data } = await api.post(`/budgets/${currentYear}/import`, { rows });
|
|
||||||
setBudgetUploaded(true);
|
|
||||||
setBudgetImportResult(data);
|
|
||||||
notifications.show({
|
|
||||||
title: 'Budget Imported',
|
|
||||||
message: `Imported ${data.imported || rows.length} budget line(s) for ${currentYear}`,
|
|
||||||
color: 'green',
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
const msg = err.response?.data?.message || 'Failed to import budget';
|
|
||||||
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Finish Wizard ──
|
// ── Finish Wizard ──
|
||||||
const handleFinish = async () => {
|
const handleFinish = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -265,13 +188,12 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
const canGoNext = () => {
|
const canGoNext = () => {
|
||||||
if (active === 0) return accountCreated;
|
if (active === 0) return accountCreated;
|
||||||
if (active === 1) return groupCreated;
|
if (active === 1) return groupCreated;
|
||||||
if (active === 2) return true; // Budget is optional
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextStep = () => {
|
const nextStep = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (active < 3) setActive(active + 1);
|
if (active < 2) setActive(active + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -315,12 +237,6 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
icon={<IconUsers size={18} />}
|
icon={<IconUsers size={18} />}
|
||||||
completedIcon={<IconCheck size={18} />}
|
completedIcon={<IconCheck size={18} />}
|
||||||
/>
|
/>
|
||||||
<Stepper.Step
|
|
||||||
label="Budget"
|
|
||||||
description="Import your annual budget"
|
|
||||||
icon={<IconFileSpreadsheet size={18} />}
|
|
||||||
completedIcon={<IconCheck size={18} />}
|
|
||||||
/>
|
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -343,6 +259,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
<Text fw={500}>{accountName} created successfully!</Text>
|
<Text fw={500}>{accountName} created successfully!</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()}
|
Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()}
|
||||||
|
{balanceDate && ` as of ${balanceDate.toLocaleDateString()}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
@@ -372,17 +289,26 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
autosize
|
autosize
|
||||||
minRows={2}
|
minRows={2}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<SimpleGrid cols={2} mb="md">
|
||||||
label="Current Balance"
|
<NumberInput
|
||||||
description="Enter the current balance of this bank account"
|
label="Current Balance"
|
||||||
placeholder="0.00"
|
description="Enter the current balance of this bank account"
|
||||||
value={initialBalance}
|
placeholder="0.00"
|
||||||
onChange={setInitialBalance}
|
value={initialBalance}
|
||||||
thousandSeparator=","
|
onChange={setInitialBalance}
|
||||||
prefix="$"
|
thousandSeparator=","
|
||||||
decimalScale={2}
|
prefix="$"
|
||||||
mb="md"
|
decimalScale={2}
|
||||||
/>
|
/>
|
||||||
|
<DateInput
|
||||||
|
label="Balance As-Of Date"
|
||||||
|
description="Date this balance was accurate (e.g. last statement date)"
|
||||||
|
value={balanceDate}
|
||||||
|
onChange={setBalanceDate}
|
||||||
|
maxDate={new Date()}
|
||||||
|
clearable={false}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateAccount}
|
onClick={handleCreateAccount}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -415,7 +341,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<SimpleGrid cols={3} mb="md">
|
<SimpleGrid cols={2} mb="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Group Name"
|
label="Group Name"
|
||||||
placeholder="e.g. Standard Assessment"
|
placeholder="e.g. Standard Assessment"
|
||||||
@@ -423,6 +349,17 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
onChange={(e) => setGroupName(e.currentTarget.value)}
|
onChange={(e) => setGroupName(e.currentTarget.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Total Unit Count"
|
||||||
|
description="How many units/lots does your community have?"
|
||||||
|
placeholder="e.g. 50"
|
||||||
|
value={unitCount}
|
||||||
|
onChange={setUnitCount}
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
<SimpleGrid cols={2} mb="md">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Assessment Amount"
|
label="Assessment Amount"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
@@ -520,71 +457,16 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Step 3: Budget Upload ── */}
|
|
||||||
{active === 2 && (
|
|
||||||
<Stack gap="md">
|
|
||||||
<Card withBorder p="lg">
|
|
||||||
<Text fw={600} mb="xs">Import Your {currentYear} Budget</Text>
|
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
|
||||||
Upload a CSV file with your annual budget. If you don't have one ready, you can download a template
|
|
||||||
or skip this step and set it up later from the Budgets page.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{budgetUploaded ? (
|
|
||||||
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
|
|
||||||
<Text fw={500}>Budget imported successfully!</Text>
|
|
||||||
{budgetImportResult && (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{budgetImportResult.created || 0} new lines created, {budgetImportResult.updated || 0} updated
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Group mb="md">
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
leftSection={<IconDownload size={16} />}
|
|
||||||
onClick={handleDownloadTemplate}
|
|
||||||
>
|
|
||||||
Download CSV Template
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<FileInput
|
|
||||||
label="Upload Budget CSV"
|
|
||||||
placeholder="Click to select a .csv file"
|
|
||||||
accept=".csv"
|
|
||||||
value={budgetFile}
|
|
||||||
onChange={setBudgetFile}
|
|
||||||
mb="md"
|
|
||||||
leftSection={<IconFileSpreadsheet size={16} />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleUploadBudget}
|
|
||||||
loading={loading}
|
|
||||||
leftSection={<IconFileSpreadsheet size={16} />}
|
|
||||||
disabled={!budgetFile}
|
|
||||||
>
|
|
||||||
Import Budget
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Completion Screen ── */}
|
{/* ── Completion Screen ── */}
|
||||||
{active === 3 && (
|
{active === 2 && (
|
||||||
<Card withBorder p="xl" style={{ textAlign: 'center' }}>
|
<Card withBorder p="xl" style={{ textAlign: 'center' }}>
|
||||||
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md">
|
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md">
|
||||||
<IconCheck size={32} />
|
<IconCheck size={32} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Title order={3} mb="xs">You're All Set!</Title>
|
<Title order={3} mb="xs">You're All Set!</Title>
|
||||||
<Text c="dimmed" mb="lg" maw={400} mx="auto">
|
<Text c="dimmed" mb="lg" maw={400} mx="auto">
|
||||||
Your organization is configured and ready to go. You can always update your accounts,
|
Your organization is configured and ready to go. You can always update your accounts
|
||||||
assessment groups, and budgets from the sidebar navigation.
|
and assessment groups from the sidebar navigation.
|
||||||
</Text>
|
</Text>
|
||||||
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
|
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
|
||||||
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
||||||
@@ -605,12 +487,17 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
||||||
<IconFileSpreadsheet size={16} />
|
<IconFileSpreadsheet size={16} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Badge color={budgetUploaded ? 'green' : 'yellow'} size="sm">
|
<Badge color="cyan" size="sm">Up Next</Badge>
|
||||||
{budgetUploaded ? 'Done' : 'Skipped'}
|
|
||||||
</Badge>
|
|
||||||
<Text size="xs" mt={4}>Budget</Text>
|
<Text size="xs" mt={4}>Budget</Text>
|
||||||
</Card>
|
</Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
<Alert icon={<IconFileSpreadsheet size={16} />} color="blue" variant="light" mb="lg" ta="left">
|
||||||
|
<Text size="sm" fw={500} mb={4}>Set Up Your Budget</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Head to <Text span fw={600}>Budget Planning</Text> from the sidebar to download a CSV template,
|
||||||
|
fill in your monthly amounts, and upload your budget. You can do this at any time.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={handleFinish}
|
onClick={handleFinish}
|
||||||
@@ -625,18 +512,13 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Navigation Buttons ── */}
|
{/* ── Navigation Buttons ── */}
|
||||||
{active < 3 && (
|
{active < 2 && (
|
||||||
<Group justify="flex-end" mt="xl">
|
<Group justify="flex-end" mt="xl">
|
||||||
{active === 2 && !budgetUploaded && (
|
|
||||||
<Button variant="subtle" onClick={nextStep}>
|
|
||||||
Skip for now
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={nextStep}
|
onClick={nextStep}
|
||||||
disabled={!canGoNext()}
|
disabled={!canGoNext()}
|
||||||
>
|
>
|
||||||
{active === 2 ? (budgetUploaded ? 'Continue' : '') : 'Next Step'}
|
Next Step
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, Text, NumberInput,
|
Title, Table, Group, Button, Stack, Text, NumberInput,
|
||||||
Select, Loader, Center, Badge, Card, Alert, Modal,
|
Select, Loader, Center, Badge, Card, Alert, Modal, ThemeIcon,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
IconDeviceFloppy, IconInfoCircle, IconPencil, IconX,
|
IconDeviceFloppy, IconInfoCircle, IconPencil, IconX,
|
||||||
IconCheck, IconArrowBack, IconTrash, IconRefresh,
|
IconCheck, IconArrowBack, IconTrash, IconRefresh,
|
||||||
IconUpload, IconDownload,
|
IconUpload, IconDownload, IconFileSpreadsheet,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
@@ -659,7 +659,37 @@ export function BudgetPlanningPage() {
|
|||||||
{lineData.length === 0 && (
|
{lineData.length === 0 && (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={15}>
|
<Table.Td colSpan={15}>
|
||||||
<Text ta="center" c="dimmed" py="lg">No budget plan lines.</Text>
|
<Card withBorder p="xl" mx="auto" maw={600} my="lg" style={{ textAlign: 'center' }}>
|
||||||
|
<ThemeIcon size={60} radius="xl" variant="light" color="blue" mx="auto" mb="md">
|
||||||
|
<IconFileSpreadsheet size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Title order={4} mb="xs">Get Started with Your {selectedYear} Budget</Title>
|
||||||
|
<Text c="dimmed" size="sm" mb="lg" maw={450} mx="auto">
|
||||||
|
Your budget plan is created but has no line items yet. Download the
|
||||||
|
CSV template pre-filled with your chart of accounts, fill in your
|
||||||
|
monthly amounts, then upload it here.
|
||||||
|
</Text>
|
||||||
|
<Group justify="center" gap="md">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconDownload size={16} />}
|
||||||
|
onClick={handleDownloadTemplate}
|
||||||
|
>
|
||||||
|
Download Budget Template
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconUpload size={16} />}
|
||||||
|
onClick={handleImportCSV}
|
||||||
|
loading={importMutation.isPending}
|
||||||
|
>
|
||||||
|
Upload Budget CSV
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed" mt="md">
|
||||||
|
Tip: The template includes all your active accounts. Fill in the monthly
|
||||||
|
dollar amounts for each line, save as CSV, then upload.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface SubscriptionInfo {
|
|||||||
trialEndsAt: string | null;
|
trialEndsAt: string | null;
|
||||||
currentPeriodEnd: string | null;
|
currentPeriodEnd: string | null;
|
||||||
cancelAtPeriodEnd: boolean;
|
cancelAtPeriodEnd: boolean;
|
||||||
|
hasStripeCustomer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
@@ -68,8 +69,9 @@ export function SettingsPage() {
|
|||||||
if (data.url) {
|
if (data.url) {
|
||||||
window.location.href = data.url;
|
window.location.href = data.url;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
notifications.show({ message: 'Unable to open billing portal', color: 'red' });
|
const msg = err.response?.data?.message || 'Unable to open billing portal';
|
||||||
|
notifications.show({ message: typeof msg === 'string' ? msg : 'Unable to open billing portal', color: 'red' });
|
||||||
} finally {
|
} finally {
|
||||||
setPortalLoading(false);
|
setPortalLoading(false);
|
||||||
}
|
}
|
||||||
@@ -167,17 +169,23 @@ export function SettingsPage() {
|
|||||||
<Badge variant="light" color="cyan" size="sm">Invoice / ACH</Badge>
|
<Badge variant="light" color="cyan" size="sm">Invoice / ACH</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
<Button
|
{subscription.hasStripeCustomer ? (
|
||||||
variant="light"
|
<Button
|
||||||
color="teal"
|
variant="light"
|
||||||
size="sm"
|
color="teal"
|
||||||
leftSection={<IconCreditCard size={16} />}
|
size="sm"
|
||||||
onClick={handleManageBilling}
|
leftSection={<IconCreditCard size={16} />}
|
||||||
loading={portalLoading}
|
onClick={handleManageBilling}
|
||||||
mt="xs"
|
loading={portalLoading}
|
||||||
>
|
mt="xs"
|
||||||
Manage Billing
|
>
|
||||||
</Button>
|
Manage Billing
|
||||||
|
</Button>
|
||||||
|
) : subscription.status === 'trial' ? (
|
||||||
|
<Text size="xs" c="dimmed" mt="xs">
|
||||||
|
Billing portal will be available once you add a payment method.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Text size="sm" c="dimmed">No active subscription</Text>
|
<Text size="sm" c="dimmed">No active subscription</Text>
|
||||||
|
|||||||
Reference in New Issue
Block a user