Quality-of-life enhancements: CSV import/export, opening balances, interest rates, mobile UX
- CSV import/export for Units, Projects, and Vendors with match-on-name/number upsert - Cash Flow report toggle for Cash Only vs Cash + Investments - Per-account and bulk opening balance setting with as-of date - Interest rate field on normal accounts with estimated monthly/annual interest display - Mobile sidebar auto-close on navigation - Shared CSV parsing/export utility extracted to frontend/src/utils/csv.ts DB migration needed for existing tenants: ALTER TABLE accounts ADD COLUMN IF NOT EXISTS interest_rate DECIMAL(6,4); Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ import { Sidebar } from './Sidebar';
|
||||
import logoSrc from '../../assets/logo.svg';
|
||||
|
||||
export function AppLayout() {
|
||||
const [opened, { toggle }] = useDisclosure();
|
||||
const [opened, { toggle, close }] = useDisclosure();
|
||||
const { user, currentOrg, logout } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -98,7 +98,7 @@ export function AppLayout() {
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar>
|
||||
<Sidebar />
|
||||
<Sidebar onNavigate={close} />
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
|
||||
@@ -77,11 +77,20 @@ const navSections = [
|
||||
},
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
interface SidebarProps {
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ onNavigate }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const go = (path: string) => {
|
||||
navigate(path);
|
||||
onNavigate?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea p="sm">
|
||||
{navSections.map((section, sIdx) => (
|
||||
@@ -109,7 +118,7 @@ export function Sidebar() {
|
||||
key={child.path}
|
||||
label={child.label}
|
||||
active={location.pathname === child.path}
|
||||
onClick={() => navigate(child.path)}
|
||||
onClick={() => go(child.path)}
|
||||
/>
|
||||
))}
|
||||
</NavLink>
|
||||
@@ -119,7 +128,7 @@ export function Sidebar() {
|
||||
label={item.label}
|
||||
leftSection={<item.icon size={18} />}
|
||||
active={location.pathname === item.path}
|
||||
onClick={() => navigate(item.path!)}
|
||||
onClick={() => go(item.path!)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
@@ -136,7 +145,7 @@ export function Sidebar() {
|
||||
label="Admin Panel"
|
||||
leftSection={<IconCrown size={18} />}
|
||||
active={location.pathname === '/admin'}
|
||||
onClick={() => navigate('/admin')}
|
||||
onClick={() => go('/admin')}
|
||||
color="red"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
IconStarFilled,
|
||||
IconAdjustments,
|
||||
IconInfoCircle,
|
||||
IconCurrencyDollar,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
@@ -71,6 +72,7 @@ interface Account {
|
||||
is_system: boolean;
|
||||
is_primary: boolean;
|
||||
balance: string;
|
||||
interest_rate: string | null;
|
||||
}
|
||||
|
||||
interface Investment {
|
||||
@@ -281,6 +283,63 @@ export function AccountsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// ── Opening balance state + mutations ──
|
||||
const [obOpened, { open: openOB, close: closeOB }] = useDisclosure(false);
|
||||
const [bulkOBOpened, { open: openBulkOB, close: closeBulkOB }] = useDisclosure(false);
|
||||
const [obAccount, setOBAccount] = useState<Account | null>(null);
|
||||
const [bulkOBDate, setBulkOBDate] = useState<Date | null>(new Date());
|
||||
const [bulkOBEntries, setBulkOBEntries] = useState<Record<string, number>>({});
|
||||
|
||||
const obForm = useForm({
|
||||
initialValues: {
|
||||
targetBalance: 0,
|
||||
asOfDate: new Date() as Date | null,
|
||||
memo: '',
|
||||
},
|
||||
validate: {
|
||||
targetBalance: (v) => (v !== undefined && v !== null ? null : 'Required'),
|
||||
asOfDate: (v) => (v ? null : 'Required'),
|
||||
},
|
||||
});
|
||||
|
||||
const openingBalanceMutation = useMutation({
|
||||
mutationFn: (values: { accountId: string; targetBalance: number; asOfDate: string; memo: string }) =>
|
||||
api.post(`/accounts/${values.accountId}/opening-balance`, {
|
||||
targetBalance: values.targetBalance,
|
||||
asOfDate: values.asOfDate,
|
||||
memo: values.memo,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['trial-balance'] });
|
||||
notifications.show({ message: 'Opening balance set successfully', color: 'green' });
|
||||
closeOB();
|
||||
setOBAccount(null);
|
||||
obForm.reset();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
notifications.show({ message: err.response?.data?.message || 'Error setting opening balance', color: 'red' });
|
||||
},
|
||||
});
|
||||
|
||||
const bulkOBMutation = useMutation({
|
||||
mutationFn: (dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] }) =>
|
||||
api.post('/accounts/bulk-opening-balances', dto),
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['trial-balance'] });
|
||||
const d = res.data;
|
||||
let msg = `Opening balances: ${d.processed} updated, ${d.skipped} unchanged`;
|
||||
if (d.errors?.length) msg += `. ${d.errors.length} error(s)`;
|
||||
notifications.show({ message: msg, color: d.errors?.length ? 'yellow' : 'green', autoClose: 10000 });
|
||||
closeBulkOB();
|
||||
setBulkOBEntries({});
|
||||
},
|
||||
onError: (err: any) => {
|
||||
notifications.show({ message: err.response?.data?.message || 'Error setting opening balances', color: 'red' });
|
||||
},
|
||||
});
|
||||
|
||||
// ── Investment edit form ──
|
||||
const invForm = useForm({
|
||||
initialValues: {
|
||||
@@ -358,6 +417,7 @@ export function AccountsPage() {
|
||||
fundType: account.fund_type,
|
||||
is1099Reportable: account.is_1099_reportable,
|
||||
initialBalance: 0,
|
||||
interestRate: parseFloat(account.interest_rate || '0'),
|
||||
});
|
||||
open();
|
||||
};
|
||||
@@ -389,6 +449,51 @@ export function AccountsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
// ── Opening balance handlers ──
|
||||
const handleSetOpeningBalance = (account: Account) => {
|
||||
setOBAccount(account);
|
||||
const tbEntry = trialBalance.find((tb) => tb.id === account.id);
|
||||
obForm.setValues({
|
||||
targetBalance: parseFloat(tbEntry?.balance || account.balance || '0'),
|
||||
asOfDate: new Date(),
|
||||
memo: '',
|
||||
});
|
||||
openOB();
|
||||
};
|
||||
|
||||
const handleOBSubmit = (values: { targetBalance: number; asOfDate: Date | null; memo: string }) => {
|
||||
if (!obAccount || !values.asOfDate) return;
|
||||
openingBalanceMutation.mutate({
|
||||
accountId: obAccount.id,
|
||||
targetBalance: values.targetBalance,
|
||||
asOfDate: values.asOfDate.toISOString().split('T')[0],
|
||||
memo: values.memo,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenBulkOB = () => {
|
||||
const entries: Record<string, number> = {};
|
||||
for (const a of accounts.filter((acc) => ['asset', 'liability'].includes(acc.account_type) && acc.is_active && !acc.is_system)) {
|
||||
const tb = trialBalance.find((t) => t.id === a.id);
|
||||
entries[a.id] = parseFloat(tb?.balance || a.balance || '0');
|
||||
}
|
||||
setBulkOBEntries(entries);
|
||||
setBulkOBDate(new Date());
|
||||
openBulkOB();
|
||||
};
|
||||
|
||||
const handleBulkOBSubmit = () => {
|
||||
if (!bulkOBDate) return;
|
||||
const entries = Object.entries(bulkOBEntries).map(([accountId, targetBalance]) => ({
|
||||
accountId,
|
||||
targetBalance,
|
||||
}));
|
||||
bulkOBMutation.mutate({
|
||||
asOfDate: bulkOBDate.toISOString().split('T')[0],
|
||||
entries,
|
||||
});
|
||||
};
|
||||
|
||||
// ── Filtering ──
|
||||
// Only show asset and liability accounts — these represent real cash positions.
|
||||
// Income, expense, and equity accounts are internal bookkeeping managed via
|
||||
@@ -434,6 +539,21 @@ export function AccountsPage() {
|
||||
// Net position = assets + investments - liabilities
|
||||
const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0);
|
||||
|
||||
// ── Estimated monthly interest across all accounts with rates ──
|
||||
const estMonthlyInterest = accounts
|
||||
.filter((a) => a.is_active && !a.is_system && a.interest_rate && parseFloat(a.interest_rate) > 0)
|
||||
.reduce((sum, a) => {
|
||||
const bal = parseFloat(a.balance || '0');
|
||||
const rate = parseFloat(a.interest_rate || '0');
|
||||
return sum + (bal * (rate / 100) / 12);
|
||||
}, 0);
|
||||
|
||||
// ── Opening balance modal: current balance ──
|
||||
const obCurrentBalance = obAccount
|
||||
? parseFloat(trialBalance.find((tb) => tb.id === obAccount.id)?.balance || obAccount.balance || '0')
|
||||
: 0;
|
||||
const obAdjustmentAmount = (obForm.values.targetBalance || 0) - obCurrentBalance;
|
||||
|
||||
// ── Adjust modal: current balance from trial balance ──
|
||||
const adjustCurrentBalance = adjustingAccount
|
||||
? parseFloat(
|
||||
@@ -463,6 +583,9 @@ export function AccountsPage() {
|
||||
onChange={(e) => setShowArchived(e.currentTarget.checked)}
|
||||
size="sm"
|
||||
/>
|
||||
<Button variant="light" leftSection={<IconCurrencyDollar size={16} />} onClick={handleOpenBulkOB}>
|
||||
Set Opening Balances
|
||||
</Button>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||
Add Account
|
||||
</Button>
|
||||
@@ -490,6 +613,12 @@ export function AccountsPage() {
|
||||
<Text size="xs" c="dimmed">Net Position</Text>
|
||||
<Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text>
|
||||
</Card>
|
||||
{estMonthlyInterest > 0 && (
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">Est. Monthly Interest</Text>
|
||||
<Text fw={700} size="sm" c="blue">{fmt(estMonthlyInterest)}</Text>
|
||||
</Card>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
<Group>
|
||||
@@ -545,6 +674,7 @@ export function AccountsPage() {
|
||||
onArchive={archiveMutation.mutate}
|
||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||
onAdjustBalance={handleAdjustBalance}
|
||||
onSetOpeningBalance={handleSetOpeningBalance}
|
||||
/>
|
||||
{investments.filter(i => i.is_active).length > 0 && (
|
||||
<>
|
||||
@@ -562,6 +692,7 @@ export function AccountsPage() {
|
||||
onArchive={archiveMutation.mutate}
|
||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||
onAdjustBalance={handleAdjustBalance}
|
||||
onSetOpeningBalance={handleSetOpeningBalance}
|
||||
/>
|
||||
{operatingInvestments.length > 0 && (
|
||||
<>
|
||||
@@ -579,6 +710,7 @@ export function AccountsPage() {
|
||||
onArchive={archiveMutation.mutate}
|
||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||
onAdjustBalance={handleAdjustBalance}
|
||||
onSetOpeningBalance={handleSetOpeningBalance}
|
||||
/>
|
||||
{reserveInvestments.length > 0 && (
|
||||
<>
|
||||
@@ -596,6 +728,7 @@ export function AccountsPage() {
|
||||
onArchive={archiveMutation.mutate}
|
||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||
onAdjustBalance={handleAdjustBalance}
|
||||
onSetOpeningBalance={handleSetOpeningBalance}
|
||||
isArchivedView
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
@@ -729,6 +862,15 @@ export function AccountsPage() {
|
||||
{/* Regular account fields */}
|
||||
{!isInvestmentType(form.values.accountType) && (
|
||||
<>
|
||||
<NumberInput
|
||||
label="Interest Rate (%)"
|
||||
description="Annual interest rate for this account"
|
||||
decimalScale={4}
|
||||
suffix="%"
|
||||
min={0}
|
||||
max={100}
|
||||
{...form.getInputProps('interestRate')}
|
||||
/>
|
||||
<Switch label="1099 Reportable" {...form.getInputProps('is1099Reportable', { type: 'checkbox' })} />
|
||||
{!editing && (
|
||||
<NumberInput
|
||||
@@ -804,6 +946,126 @@ export function AccountsPage() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Opening Balance Modal */}
|
||||
<Modal opened={obOpened} onClose={closeOB} title="Set Opening Balance" size="md" closeOnClickOutside={false}>
|
||||
{obAccount && (
|
||||
<form onSubmit={obForm.onSubmit(handleOBSubmit)}>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
Account: <strong>{obAccount.account_number} - {obAccount.name}</strong>
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label="Current Balance"
|
||||
value={fmt(obCurrentBalance)}
|
||||
readOnly
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Target Opening Balance"
|
||||
description="The balance this account should have as of the selected date"
|
||||
required
|
||||
prefix="$"
|
||||
decimalScale={2}
|
||||
thousandSeparator=","
|
||||
allowNegative
|
||||
{...obForm.getInputProps('targetBalance')}
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
label="As-of Date"
|
||||
description="The date the balance should be effective"
|
||||
required
|
||||
clearable
|
||||
{...obForm.getInputProps('asOfDate')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Memo"
|
||||
placeholder="Optional memo"
|
||||
{...obForm.getInputProps('memo')}
|
||||
/>
|
||||
|
||||
<Alert icon={<IconInfoCircle size={16} />} color={obAdjustmentAmount >= 0 ? 'blue' : 'orange'} variant="light">
|
||||
<Text size="sm">
|
||||
Adjustment: <strong>{fmt(obAdjustmentAmount)}</strong>
|
||||
{obAdjustmentAmount > 0 && ' (increase)'}
|
||||
{obAdjustmentAmount < 0 && ' (decrease)'}
|
||||
{obAdjustmentAmount === 0 && ' (no change)'}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Button type="submit" loading={openingBalanceMutation.isPending}>
|
||||
Set Opening Balance
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Bulk Opening Balance Modal */}
|
||||
<Modal opened={bulkOBOpened} onClose={closeBulkOB} title="Set Opening Balances" size="xl" closeOnClickOutside={false}>
|
||||
<Stack>
|
||||
<DateInput
|
||||
label="As-of Date"
|
||||
description="All opening balances will be effective as of this date"
|
||||
required
|
||||
value={bulkOBDate}
|
||||
onChange={setBulkOBDate}
|
||||
/>
|
||||
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Acct #</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Fund</Table.Th>
|
||||
<Table.Th ta="right">Current Balance</Table.Th>
|
||||
<Table.Th ta="right">Target Balance</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{accounts
|
||||
.filter((a) => ['asset', 'liability'].includes(a.account_type) && a.is_active && !a.is_system)
|
||||
.map((a) => {
|
||||
const currentBal = parseFloat(trialBalance.find((t) => t.id === a.id)?.balance || a.balance || '0');
|
||||
return (
|
||||
<Table.Tr key={a.id}>
|
||||
<Table.Td>{a.account_number}</Table.Td>
|
||||
<Table.Td>{a.name}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={accountTypeColors[a.account_type]} variant="light" size="sm">{a.account_type}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={a.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">{a.fund_type}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(currentBal)}</Table.Td>
|
||||
<Table.Td>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
prefix="$"
|
||||
decimalScale={2}
|
||||
thousandSeparator=","
|
||||
allowNegative
|
||||
value={bulkOBEntries[a.id] ?? currentBal}
|
||||
onChange={(v) => setBulkOBEntries((prev) => ({ ...prev, [a.id]: Number(v) || 0 }))}
|
||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Button onClick={handleBulkOBSubmit} loading={bulkOBMutation.isPending}>
|
||||
Apply Opening Balances
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Investment Edit Modal */}
|
||||
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
|
||||
{editingInvestment && (
|
||||
@@ -888,6 +1150,7 @@ function AccountTable({
|
||||
onArchive,
|
||||
onSetPrimary,
|
||||
onAdjustBalance,
|
||||
onSetOpeningBalance,
|
||||
isArchivedView = false,
|
||||
}: {
|
||||
accounts: Account[];
|
||||
@@ -895,8 +1158,11 @@ function AccountTable({
|
||||
onArchive: (a: Account) => void;
|
||||
onSetPrimary: (id: string) => void;
|
||||
onAdjustBalance: (a: Account) => void;
|
||||
onSetOpeningBalance: (a: Account) => void;
|
||||
isArchivedView?: boolean;
|
||||
}) {
|
||||
const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0);
|
||||
|
||||
return (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
@@ -907,6 +1173,9 @@ function AccountTable({
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Fund</Table.Th>
|
||||
<Table.Th ta="right">Balance</Table.Th>
|
||||
{hasRates && <Table.Th ta="right">Rate</Table.Th>}
|
||||
{hasRates && <Table.Th ta="right">Est. Monthly</Table.Th>}
|
||||
{hasRates && <Table.Th ta="right">Est. Annual</Table.Th>}
|
||||
<Table.Th>1099</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
@@ -914,89 +1183,117 @@ function AccountTable({
|
||||
<Table.Tbody>
|
||||
{accounts.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={8}>
|
||||
<Table.Td colSpan={hasRates ? 11 : 8}>
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
{isArchivedView ? 'No archived accounts' : 'No accounts found'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{accounts.map((a) => (
|
||||
<Table.Tr key={a.id} style={{ opacity: a.is_active ? 1 : 0.6 }}>
|
||||
<Table.Td>
|
||||
{a.is_primary && (
|
||||
<Tooltip label="Primary account">
|
||||
<IconStarFilled size={16} style={{ color: 'var(--mantine-color-yellow-5)' }} />
|
||||
</Tooltip>
|
||||
{accounts.map((a) => {
|
||||
const rate = parseFloat(a.interest_rate || '0');
|
||||
const balance = parseFloat(a.balance || '0');
|
||||
const estAnnual = rate > 0 ? balance * (rate / 100) : 0;
|
||||
const estMonthly = estAnnual / 12;
|
||||
return (
|
||||
<Table.Tr key={a.id} style={{ opacity: a.is_active ? 1 : 0.6 }}>
|
||||
<Table.Td>
|
||||
{a.is_primary && (
|
||||
<Tooltip label="Primary account">
|
||||
<IconStarFilled size={16} style={{ color: 'var(--mantine-color-yellow-5)' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td fw={500}>{a.account_number}</Table.Td>
|
||||
<Table.Td>
|
||||
<div>
|
||||
<Text size="sm">{a.name}</Text>
|
||||
{a.description && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{a.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={accountTypeColors[a.account_type]} variant="light" size="sm">
|
||||
{a.account_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={a.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
|
||||
{a.fund_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">
|
||||
{fmt(a.balance)}
|
||||
</Table.Td>
|
||||
{hasRates && (
|
||||
<Table.Td ta="right">
|
||||
{rate > 0 ? `${rate.toFixed(2)}%` : '-'}
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td fw={500}>{a.account_number}</Table.Td>
|
||||
<Table.Td>
|
||||
<div>
|
||||
<Text size="sm">{a.name}</Text>
|
||||
{a.description && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{a.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={accountTypeColors[a.account_type]} variant="light" size="sm">
|
||||
{a.account_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={a.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
|
||||
{a.fund_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">
|
||||
{fmt(a.balance)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
{!a.is_system && (
|
||||
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="yellow"
|
||||
onClick={() => onSetPrimary(a.id)}
|
||||
>
|
||||
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />}
|
||||
{hasRates && (
|
||||
<Table.Td ta="right" ff="monospace">
|
||||
{rate > 0 ? fmt(estMonthly) : '-'}
|
||||
</Table.Td>
|
||||
)}
|
||||
{hasRates && (
|
||||
<Table.Td ta="right" ff="monospace">
|
||||
{rate > 0 ? fmt(estAnnual) : '-'}
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
{!a.is_system && (
|
||||
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="yellow"
|
||||
onClick={() => onSetPrimary(a.id)}
|
||||
>
|
||||
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!a.is_system && (
|
||||
<Tooltip label="Set Opening Balance">
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => onSetOpeningBalance(a)}>
|
||||
<IconCurrencyDollar size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!a.is_system && (
|
||||
<Tooltip label="Adjust Balance">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
|
||||
<IconAdjustments size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Edit account">
|
||||
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!a.is_system && (
|
||||
<Tooltip label="Adjust Balance">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
|
||||
<IconAdjustments size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Edit account">
|
||||
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{!a.is_system && (
|
||||
<Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={a.is_active ? 'gray' : 'green'}
|
||||
onClick={() => onArchive(a)}
|
||||
>
|
||||
{a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{!a.is_system && (
|
||||
<Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={a.is_active ? 'gray' : 'green'}
|
||||
onClick={() => onArchive(a)}
|
||||
>
|
||||
{a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
|
||||
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
|
||||
@@ -8,9 +8,10 @@ import { DateInput } from '@mantine/dates';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
||||
import { IconPlus, IconEdit, IconUpload, IconDownload } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
@@ -75,6 +76,7 @@ export function ProjectsPage() {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [editing, setEditing] = useState<Project | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ---- Data fetching ----
|
||||
|
||||
@@ -191,6 +193,42 @@ export function ProjectsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (rows: Record<string, string>[]) => {
|
||||
const { data } = await api.post('/projects/import', rows);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
let msg = `Imported: ${data.created} created, ${data.updated} updated`;
|
||||
if (data.errors?.length) msg += `. ${data.errors.length} error(s): ${data.errors.slice(0, 3).join('; ')}`;
|
||||
notifications.show({ message: msg, color: data.errors?.length ? 'yellow' : 'green', autoClose: 10000 });
|
||||
},
|
||||
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' }); },
|
||||
});
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const response = await api.get('/projects/export', { responseType: 'blob' });
|
||||
downloadBlob(response.data, 'projects.csv');
|
||||
} catch { notifications.show({ message: 'Export failed', color: 'red' }); }
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
if (!text) { notifications.show({ message: 'Could not read file', color: 'red' }); return; }
|
||||
const rows = parseCSV(text);
|
||||
if (!rows.length) { notifications.show({ message: 'No data rows found', color: 'red' }); return; }
|
||||
importMutation.mutate(rows);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
// ---- Handlers ----
|
||||
|
||||
const handleEdit = (p: Project) => {
|
||||
@@ -279,9 +317,19 @@ export function ProjectsPage() {
|
||||
{/* Header */}
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Projects</Title>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||
+ Add Project
|
||||
</Button>
|
||||
<Group>
|
||||
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={projects.length === 0}>
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
||||
loading={importMutation.isPending}>
|
||||
Import CSV
|
||||
</Button>
|
||||
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||
+ Add Project
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Summary Cards */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Stack, Text, Card, Loader, Center, Divider,
|
||||
Badge, SimpleGrid, TextInput, Button, ThemeIcon,
|
||||
Badge, SimpleGrid, TextInput, Button, ThemeIcon, SegmentedControl,
|
||||
} from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -24,6 +24,7 @@ interface ReserveActivity {
|
||||
interface CashFlowData {
|
||||
from: string;
|
||||
to: string;
|
||||
include_investments: boolean;
|
||||
operating_activities: OperatingActivity[];
|
||||
reserve_activities: ReserveActivity[];
|
||||
total_operating: string;
|
||||
@@ -31,6 +32,7 @@ interface CashFlowData {
|
||||
net_cash_change: string;
|
||||
beginning_cash: string;
|
||||
ending_cash: string;
|
||||
investment_balance: string;
|
||||
}
|
||||
|
||||
export function CashFlowPage() {
|
||||
@@ -42,11 +44,16 @@ export function CashFlowPage() {
|
||||
const [toDate, setToDate] = useState(todayStr);
|
||||
const [queryFrom, setQueryFrom] = useState(yearStart);
|
||||
const [queryTo, setQueryTo] = useState(todayStr);
|
||||
const [balanceMode, setBalanceMode] = useState<string>('cash');
|
||||
|
||||
const includeInvestments = balanceMode === 'all';
|
||||
|
||||
const { data, isLoading } = useQuery<CashFlowData>({
|
||||
queryKey: ['cash-flow', queryFrom, queryTo],
|
||||
queryKey: ['cash-flow', queryFrom, queryTo, includeInvestments],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/reports/cash-flow?from=${queryFrom}&to=${queryTo}`);
|
||||
const params = new URLSearchParams({ from: queryFrom, to: queryTo });
|
||||
if (includeInvestments) params.set('includeInvestments', 'true');
|
||||
const { data } = await api.get(`/reports/cash-flow?${params}`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
@@ -63,6 +70,7 @@ export function CashFlowPage() {
|
||||
const totalReserve = parseFloat(data?.total_reserve || '0');
|
||||
const beginningCash = parseFloat(data?.beginning_cash || '0');
|
||||
const endingCash = parseFloat(data?.ending_cash || '0');
|
||||
const balanceLabel = includeInvestments ? 'Cash + Investments' : 'Cash';
|
||||
|
||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||
|
||||
@@ -95,6 +103,19 @@ export function CashFlowPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Text size="sm" fw={500}>Balance view:</Text>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={balanceMode}
|
||||
onChange={setBalanceMode}
|
||||
data={[
|
||||
{ label: 'Cash Only', value: 'cash' },
|
||||
{ label: 'Cash + Investments', value: 'all' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
|
||||
<Card withBorder p="md">
|
||||
@@ -102,7 +123,7 @@ export function CashFlowPage() {
|
||||
<ThemeIcon variant="light" color="blue" size="sm">
|
||||
<IconWallet size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Beginning Cash</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Beginning {balanceLabel}</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace">{fmt(beginningCash)}</Text>
|
||||
</Card>
|
||||
@@ -133,7 +154,7 @@ export function CashFlowPage() {
|
||||
<ThemeIcon variant="light" color="teal" size="sm">
|
||||
<IconCash size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Ending Cash</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Ending {balanceLabel}</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace">{fmt(endingCash)}</Text>
|
||||
</Card>
|
||||
@@ -162,11 +183,7 @@ export function CashFlowPage() {
|
||||
<Table.Tr key={`${a.name}-${idx}`}>
|
||||
<Table.Td>{a.name}</Table.Td>
|
||||
<Table.Td ta="center">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={a.type === 'income' ? 'green' : 'red'}
|
||||
>
|
||||
<Badge size="xs" variant="light" color={a.type === 'income' ? 'green' : 'red'}>
|
||||
{a.type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
@@ -241,11 +258,11 @@ export function CashFlowPage() {
|
||||
</Group>
|
||||
<Divider />
|
||||
<Group justify="space-between" px="sm">
|
||||
<Text fw={700} size="lg">Beginning Cash</Text>
|
||||
<Text fw={700} size="lg">Beginning {balanceLabel}</Text>
|
||||
<Text fw={700} size="lg" ff="monospace">{fmt(data?.beginning_cash || '0')}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between" px="sm">
|
||||
<Text fw={700} size="xl">Ending Cash</Text>
|
||||
<Text fw={700} size="xl">Ending {balanceLabel}</Text>
|
||||
<Text fw={700} size="xl" ff="monospace" c="teal">
|
||||
{fmt(data?.ending_cash || '0')}
|
||||
</Text>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Button, Stack, TextInput, Modal,
|
||||
Select, Badge, ActionIcon, Text, Loader, Center, Tooltip, Alert,
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle, IconUpload, IconDownload } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||
|
||||
interface Unit {
|
||||
id: string;
|
||||
@@ -39,6 +40,7 @@ export function UnitsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: units = [], isLoading } = useQuery<Unit[]>({
|
||||
queryKey: ['units'],
|
||||
@@ -91,6 +93,20 @@ export function UnitsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (rows: Record<string, string>[]) => {
|
||||
const { data } = await api.post('/units/import', rows);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['units'] });
|
||||
let msg = `Imported: ${data.created} created, ${data.updated} updated`;
|
||||
if (data.errors?.length) msg += `. ${data.errors.length} error(s): ${data.errors.slice(0, 3).join('; ')}`;
|
||||
notifications.show({ message: msg, color: data.errors?.length ? 'yellow' : 'green', autoClose: 10000 });
|
||||
},
|
||||
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' }); },
|
||||
});
|
||||
|
||||
const handleEdit = (u: Unit) => {
|
||||
setEditing(u);
|
||||
form.setValues({
|
||||
@@ -105,13 +121,32 @@ export function UnitsPage() {
|
||||
const handleNew = () => {
|
||||
setEditing(null);
|
||||
form.reset();
|
||||
// Pre-populate with default group
|
||||
if (defaultGroup) {
|
||||
form.setFieldValue('assessment_group_id', defaultGroup.id);
|
||||
}
|
||||
if (defaultGroup) form.setFieldValue('assessment_group_id', defaultGroup.id);
|
||||
open();
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const response = await api.get('/units/export', { responseType: 'blob' });
|
||||
downloadBlob(response.data, 'units.csv');
|
||||
} catch { notifications.show({ message: 'Export failed', color: 'red' }); }
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
if (!text) { notifications.show({ message: 'Could not read file', color: 'red' }); return; }
|
||||
const rows = parseCSV(text);
|
||||
if (!rows.length) { notifications.show({ message: 'No data rows found', color: 'red' }); return; }
|
||||
importMutation.mutate(rows);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const filtered = units.filter((u) =>
|
||||
!search || u.unit_number.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(u.owner_name || '').toLowerCase().includes(search.toLowerCase())
|
||||
@@ -123,13 +158,23 @@ export function UnitsPage() {
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Units / Homeowners</Title>
|
||||
{hasGroups ? (
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Add Unit</Button>
|
||||
) : (
|
||||
<Tooltip label="Create an assessment group first">
|
||||
<Button leftSection={<IconPlus size={16} />} disabled>Add Unit</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Group>
|
||||
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={units.length === 0}>
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
||||
loading={importMutation.isPending}>
|
||||
Import CSV
|
||||
</Button>
|
||||
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
{hasGroups ? (
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Add Unit</Button>
|
||||
) : (
|
||||
<Tooltip label="Create an assessment group first">
|
||||
<Button leftSection={<IconPlus size={16} />} disabled>Add Unit</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!hasGroups && (
|
||||
|
||||
54
frontend/src/pages/vendors/VendorsPage.tsx
vendored
54
frontend/src/pages/vendors/VendorsPage.tsx
vendored
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Button, Stack, TextInput, Modal,
|
||||
Switch, Badge, ActionIcon, Text, Loader, Center,
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconPlus, IconEdit, IconSearch } from '@tabler/icons-react';
|
||||
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||
|
||||
interface Vendor {
|
||||
id: string; name: string; contact_name: string; email: string; phone: string;
|
||||
@@ -21,6 +22,7 @@ export function VendorsPage() {
|
||||
const [editing, setEditing] = useState<Vendor | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
|
||||
queryKey: ['vendors'],
|
||||
@@ -46,6 +48,42 @@ export function VendorsPage() {
|
||||
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
|
||||
});
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (rows: Record<string, string>[]) => {
|
||||
const { data } = await api.post('/vendors/import', rows);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vendors'] });
|
||||
let msg = `Imported: ${data.created} created, ${data.updated} updated`;
|
||||
if (data.errors?.length) msg += `. ${data.errors.length} error(s): ${data.errors.slice(0, 3).join('; ')}`;
|
||||
notifications.show({ message: msg, color: data.errors?.length ? 'yellow' : 'green', autoClose: 10000 });
|
||||
},
|
||||
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' }); },
|
||||
});
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const response = await api.get('/vendors/export', { responseType: 'blob' });
|
||||
downloadBlob(response.data, 'vendors.csv');
|
||||
} catch { notifications.show({ message: 'Export failed', color: 'red' }); }
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
if (!text) { notifications.show({ message: 'Could not read file', color: 'red' }); return; }
|
||||
const rows = parseCSV(text);
|
||||
if (!rows.length) { notifications.show({ message: 'No data rows found', color: 'red' }); return; }
|
||||
importMutation.mutate(rows);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const handleEdit = (v: Vendor) => {
|
||||
setEditing(v);
|
||||
form.setValues({
|
||||
@@ -65,7 +103,17 @@ export function VendorsPage() {
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Vendors</Title>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Vendor</Button>
|
||||
<Group>
|
||||
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={vendors.length === 0}>
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
||||
loading={importMutation.isPending}>
|
||||
Import CSV
|
||||
</Button>
|
||||
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Vendor</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<TextInput placeholder="Search vendors..." leftSection={<IconSearch size={16} />}
|
||||
value={search} onChange={(e) => setSearch(e.currentTarget.value)} />
|
||||
|
||||
84
frontend/src/utils/csv.ts
Normal file
84
frontend/src/utils/csv.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Shared CSV parsing and export utilities.
|
||||
*/
|
||||
|
||||
/** Parse CSV text into an array of objects keyed by lowercase header names. */
|
||||
export function parseCSV(text: string): Record<string, string>[] {
|
||||
const lines = text.trim().split('\n');
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
// Strip leading * from headers (used to mark required fields)
|
||||
const headers = lines[0].split(',').map((h) => h.trim().replace(/^\*/, '').toLowerCase());
|
||||
const rows: Record<string, string>[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
// Handle quoted fields containing commas
|
||||
const values: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (let j = 0; j < line.length; j++) {
|
||||
const ch = line[j];
|
||||
if (ch === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (ch === ',' && !inQuotes) {
|
||||
values.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
values.push(current.trim());
|
||||
|
||||
const row: Record<string, string> = {};
|
||||
headers.forEach((h, idx) => {
|
||||
row[h] = values[idx] || '';
|
||||
});
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Convert an array of objects to CSV text and trigger a browser download. */
|
||||
export function downloadCSV(rows: Record<string, any>[], headers: string[], filename: string) {
|
||||
const csvLines = [headers.join(',')];
|
||||
for (const row of rows) {
|
||||
const values = headers.map((h) => {
|
||||
const key = h.replace(/^\*/, '').toLowerCase();
|
||||
const val = row[key] ?? '';
|
||||
const str = String(val);
|
||||
// Quote values that contain commas or quotes
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
});
|
||||
csvLines.push(values.join(','));
|
||||
}
|
||||
|
||||
const blob = new Blob([csvLines.join('\n')], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Download a blob response from the API as a file. */
|
||||
export function downloadBlob(data: Blob, filename: string, type = 'text/csv') {
|
||||
const blob = new Blob([data], { type });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
Reference in New Issue
Block a user