Fix bugs: monthly actuals month filter, unit assessments, project funding logic, UI cleanup
- Fix monthly actuals showing same data for all months (SQL JOIN excluded month filter from SUM — added je.id IS NOT NULL guard) - Fix units displaying $0 assessment by reading from assessment group instead of stale unit field; add special assessment column - Replace proportional project funding with priority-based sequential allocation — near-term items get fully funded first; add is_funding_locked flag so users can manually lock a project's fund balance - Remove post-creation opening balance UI (keep only initial balance on account creation); remove redundant Fund filter dropdown from Accounts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -311,6 +311,7 @@ export class TenantSchemaService {
|
|||||||
account_id UUID REFERENCES "${s}".accounts(id),
|
account_id UUID REFERENCES "${s}".accounts(id),
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_funding_locked BOOLEAN DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ export class MonthlyActualsService {
|
|||||||
a.fund_type,
|
a.fund_type,
|
||||||
COALESCE(b.${budgetCol}, 0) as budget_amount,
|
COALESCE(b.${budgetCol}, 0) as budget_amount,
|
||||||
COALESCE(SUM(
|
COALESCE(SUM(
|
||||||
|
CASE WHEN je.id IS NOT NULL THEN
|
||||||
CASE WHEN a.account_type IN ('expense', 'asset') THEN jel.debit - jel.credit
|
CASE WHEN a.account_type IN ('expense', 'asset') THEN jel.debit - jel.credit
|
||||||
ELSE jel.credit - jel.debit END
|
ELSE jel.credit - jel.debit END
|
||||||
|
END
|
||||||
), 0) as actual_amount
|
), 0) as actual_amount
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
LEFT JOIN budgets b ON b.account_id = a.id AND b.fiscal_year = $1
|
LEFT JOIN budgets b ON b.account_id = a.id AND b.fiscal_year = $1
|
||||||
|
|||||||
@@ -6,50 +6,10 @@ export class ProjectsService {
|
|||||||
constructor(private tenant: TenantService) {}
|
constructor(private tenant: TenantService) {}
|
||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
// Return all active projects with dynamically computed reserve fund balance
|
const projects = await this.tenant.query(
|
||||||
// The total reserve fund balance (from reserve EQUITY accounts = fund balance) is distributed
|
'SELECT * FROM projects WHERE is_active = true ORDER BY name',
|
||||||
// proportionally across reserve projects based on their estimated_cost
|
);
|
||||||
return this.tenant.query(`
|
return this.computeFunding(projects);
|
||||||
WITH reserve_equity AS (
|
|
||||||
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
|
||||||
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
|
|
||||||
FROM accounts a
|
|
||||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
|
||||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
|
||||||
WHERE a.fund_type = 'reserve' AND a.account_type = 'equity' AND a.is_active = true
|
|
||||||
GROUP BY a.id
|
|
||||||
) sub
|
|
||||||
),
|
|
||||||
reserve_investments AS (
|
|
||||||
SELECT COALESCE(SUM(current_value), 0) as total
|
|
||||||
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
|
|
||||||
),
|
|
||||||
reserve_balance AS (
|
|
||||||
SELECT re.total + ri.total as total
|
|
||||||
FROM reserve_equity re, reserve_investments ri
|
|
||||||
),
|
|
||||||
reserve_total_cost AS (
|
|
||||||
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
|
||||||
FROM projects
|
|
||||||
WHERE is_active = true AND fund_source = 'reserve' AND estimated_cost > 0
|
|
||||||
)
|
|
||||||
SELECT p.*,
|
|
||||||
CASE
|
|
||||||
WHEN p.fund_source = 'reserve' AND p.estimated_cost > 0 AND rtc.total > 0 THEN
|
|
||||||
LEAST(ROUND((rb.total * (p.estimated_cost / rtc.total)) / p.estimated_cost * 100, 2), 100)
|
|
||||||
ELSE p.funded_percentage
|
|
||||||
END as funded_percentage,
|
|
||||||
CASE
|
|
||||||
WHEN p.fund_source = 'reserve' AND rtc.total > 0 THEN
|
|
||||||
ROUND(rb.total * (p.estimated_cost / rtc.total), 2)
|
|
||||||
ELSE p.current_fund_balance
|
|
||||||
END as current_fund_balance
|
|
||||||
FROM projects p
|
|
||||||
CROSS JOIN reserve_balance rb
|
|
||||||
CROSS JOIN reserve_total_cost rtc
|
|
||||||
WHERE p.is_active = true
|
|
||||||
ORDER BY p.name
|
|
||||||
`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string) {
|
async findOne(id: string) {
|
||||||
@@ -59,11 +19,27 @@ export class ProjectsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findForPlanning() {
|
async findForPlanning() {
|
||||||
// Only return projects that have target_year set (for the Capital Planning kanban)
|
const projects = await this.tenant.query(
|
||||||
// Uses the same dynamic reserve fund balance computation as findAll()
|
'SELECT * FROM projects WHERE is_active = true AND target_year IS NOT NULL ORDER BY target_year, target_month NULLS LAST, priority',
|
||||||
return this.tenant.query(`
|
);
|
||||||
WITH reserve_equity AS (
|
return this.computeFunding(projects);
|
||||||
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority-based funding allocation for reserve projects.
|
||||||
|
*
|
||||||
|
* 1. Projects with is_funding_locked = true keep their stored funded_percentage
|
||||||
|
* and current_fund_balance values as-is.
|
||||||
|
* 2. Remaining reserve balance (after deducting locked amounts) is allocated
|
||||||
|
* sequentially to unlocked reserve projects sorted by target_year, target_month,
|
||||||
|
* priority — near-term items get fully funded first.
|
||||||
|
*/
|
||||||
|
private async computeFunding(projects: any[]): Promise<any[]> {
|
||||||
|
// Get total reserve balance (equity + investments)
|
||||||
|
const [balanceRow] = await this.tenant.query(`
|
||||||
|
SELECT
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(sub.balance) FROM (
|
||||||
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
|
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
@@ -71,37 +47,75 @@ export class ProjectsService {
|
|||||||
WHERE a.fund_type = 'reserve' AND a.account_type = 'equity' AND a.is_active = true
|
WHERE a.fund_type = 'reserve' AND a.account_type = 'equity' AND a.is_active = true
|
||||||
GROUP BY a.id
|
GROUP BY a.id
|
||||||
) sub
|
) sub
|
||||||
),
|
), 0) +
|
||||||
reserve_investments AS (
|
COALESCE((
|
||||||
SELECT COALESCE(SUM(current_value), 0) as total
|
SELECT SUM(current_value) FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
|
||||||
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
|
), 0) as total
|
||||||
),
|
|
||||||
reserve_balance AS (
|
|
||||||
SELECT re.total + ri.total as total
|
|
||||||
FROM reserve_equity re, reserve_investments ri
|
|
||||||
),
|
|
||||||
reserve_total_cost AS (
|
|
||||||
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
|
||||||
FROM projects
|
|
||||||
WHERE is_active = true AND fund_source = 'reserve' AND estimated_cost > 0
|
|
||||||
)
|
|
||||||
SELECT p.*,
|
|
||||||
CASE
|
|
||||||
WHEN p.fund_source = 'reserve' AND p.estimated_cost > 0 AND rtc.total > 0 THEN
|
|
||||||
LEAST(ROUND((rb.total * (p.estimated_cost / rtc.total)) / p.estimated_cost * 100, 2), 100)
|
|
||||||
ELSE p.funded_percentage
|
|
||||||
END as funded_percentage,
|
|
||||||
CASE
|
|
||||||
WHEN p.fund_source = 'reserve' AND rtc.total > 0 THEN
|
|
||||||
ROUND(rb.total * (p.estimated_cost / rtc.total), 2)
|
|
||||||
ELSE p.current_fund_balance
|
|
||||||
END as current_fund_balance
|
|
||||||
FROM projects p
|
|
||||||
CROSS JOIN reserve_balance rb
|
|
||||||
CROSS JOIN reserve_total_cost rtc
|
|
||||||
WHERE p.is_active = true AND p.target_year IS NOT NULL
|
|
||||||
ORDER BY p.target_year, p.target_month NULLS LAST, p.priority
|
|
||||||
`);
|
`);
|
||||||
|
const totalReserve = parseFloat(balanceRow?.total || '0');
|
||||||
|
|
||||||
|
// Separate locked and unlocked reserve projects
|
||||||
|
const lockedReserve: any[] = [];
|
||||||
|
const unlockedReserve: any[] = [];
|
||||||
|
|
||||||
|
for (const p of projects) {
|
||||||
|
if (p.fund_source === 'reserve' && !p.is_funding_locked) {
|
||||||
|
unlockedReserve.push(p);
|
||||||
|
} else if (p.fund_source === 'reserve' && p.is_funding_locked) {
|
||||||
|
lockedReserve.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduct locked amounts from available reserve balance
|
||||||
|
const lockedTotal = lockedReserve.reduce((sum, p) => sum + parseFloat(p.current_fund_balance || '0'), 0);
|
||||||
|
let remaining = Math.max(totalReserve - lockedTotal, 0);
|
||||||
|
|
||||||
|
// Sort unlocked by target_year, target_month, priority for sequential allocation
|
||||||
|
unlockedReserve.sort((a, b) => {
|
||||||
|
const ya = a.target_year || 9999;
|
||||||
|
const yb = b.target_year || 9999;
|
||||||
|
if (ya !== yb) return ya - yb;
|
||||||
|
const ma = a.target_month || 13;
|
||||||
|
const mb = b.target_month || 13;
|
||||||
|
if (ma !== mb) return ma - mb;
|
||||||
|
return (a.priority || 3) - (b.priority || 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allocate remaining balance sequentially: near-term items first
|
||||||
|
const fundingMap = new Map<string, { funded_percentage: number; current_fund_balance: number }>();
|
||||||
|
|
||||||
|
// Locked projects keep their stored values
|
||||||
|
for (const p of lockedReserve) {
|
||||||
|
fundingMap.set(p.id, {
|
||||||
|
funded_percentage: parseFloat(p.funded_percentage || '0'),
|
||||||
|
current_fund_balance: parseFloat(p.current_fund_balance || '0'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlocked projects get sequential allocation
|
||||||
|
for (const p of unlockedReserve) {
|
||||||
|
const cost = parseFloat(p.estimated_cost || '0');
|
||||||
|
if (cost <= 0) {
|
||||||
|
fundingMap.set(p.id, { funded_percentage: 0, current_fund_balance: 0 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const allocated = Math.min(cost, remaining);
|
||||||
|
remaining -= allocated;
|
||||||
|
const pct = Math.min((allocated / cost) * 100, 100);
|
||||||
|
fundingMap.set(p.id, {
|
||||||
|
funded_percentage: Math.round(pct * 100) / 100,
|
||||||
|
current_fund_balance: Math.round(allocated * 100) / 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply computed funding to all projects
|
||||||
|
return projects.map((p) => {
|
||||||
|
const funding = fundingMap.get(p.id);
|
||||||
|
if (funding) {
|
||||||
|
return { ...p, funded_percentage: funding.funded_percentage, current_fund_balance: funding.current_fund_balance };
|
||||||
|
}
|
||||||
|
return p; // non-reserve projects keep stored values
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: any) {
|
async create(dto: any) {
|
||||||
@@ -116,8 +130,8 @@ export class ProjectsService {
|
|||||||
current_fund_balance, annual_contribution, fund_source, funded_percentage,
|
current_fund_balance, annual_contribution, fund_source, funded_percentage,
|
||||||
useful_life_years, remaining_life_years, condition_rating,
|
useful_life_years, remaining_life_years, condition_rating,
|
||||||
last_replacement_date, next_replacement_date, planned_date,
|
last_replacement_date, next_replacement_date, planned_date,
|
||||||
target_year, target_month, status, priority, account_id, notes
|
target_year, target_month, status, priority, account_id, notes, is_funding_locked
|
||||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
dto.name, dto.description || null, dto.category || null,
|
dto.name, dto.description || null, dto.category || null,
|
||||||
@@ -131,6 +145,7 @@ export class ProjectsService {
|
|||||||
dto.target_year || null, dto.target_month || null,
|
dto.target_year || null, dto.target_month || null,
|
||||||
dto.status || 'planned', dto.priority || 3,
|
dto.status || 'planned', dto.priority || 3,
|
||||||
dto.account_id || null, dto.notes || null,
|
dto.account_id || null, dto.notes || null,
|
||||||
|
dto.is_funding_locked || false,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
@@ -154,7 +169,7 @@ export class ProjectsService {
|
|||||||
['planned_date', 'planned_date'],
|
['planned_date', 'planned_date'],
|
||||||
['target_year', 'target_year'], ['target_month', 'target_month'],
|
['target_year', 'target_year'], ['target_month', 'target_month'],
|
||||||
['status', 'status'], ['priority', 'priority'],
|
['status', 'status'], ['priority', 'priority'],
|
||||||
['account_id', 'account_id'], ['notes', 'notes'], ['is_active', 'is_active'],
|
['account_id', 'account_id'], ['notes', 'notes'], ['is_active', 'is_active'], ['is_funding_locked', 'is_funding_locked'],
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [dtoKey, dbCol] of fields) {
|
for (const [dtoKey, dbCol] of fields) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export class UnitsService {
|
|||||||
SELECT u.*,
|
SELECT u.*,
|
||||||
ag.name as assessment_group_name,
|
ag.name as assessment_group_name,
|
||||||
ag.regular_assessment as group_regular_assessment,
|
ag.regular_assessment as group_regular_assessment,
|
||||||
|
ag.special_assessment as group_special_assessment,
|
||||||
ag.frequency as group_frequency,
|
ag.frequency as group_frequency,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT SUM(i.amount - i.amount_paid)
|
SELECT SUM(i.amount - i.amount_paid)
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import {
|
|||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
IconAdjustments,
|
IconAdjustments,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
IconCurrencyDollar,
|
|
||||||
} 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';
|
||||||
@@ -125,7 +124,6 @@ export function AccountsPage() {
|
|||||||
const [adjustingAccount, setAdjustingAccount] = useState<Account | null>(null);
|
const [adjustingAccount, setAdjustingAccount] = useState<Account | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filterType, setFilterType] = useState<string | null>(null);
|
const [filterType, setFilterType] = useState<string | null>(null);
|
||||||
const [filterFund, setFilterFund] = useState<string | null>(null);
|
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -283,63 +281,6 @@ 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 ──
|
// ── Investment edit form ──
|
||||||
const invForm = useForm({
|
const invForm = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
@@ -449,51 +390,6 @@ 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 ──
|
// ── Filtering ──
|
||||||
// Only show asset and liability accounts — these represent real cash positions.
|
// Only show asset and liability accounts — these represent real cash positions.
|
||||||
// Income, expense, and equity accounts are internal bookkeeping managed via
|
// Income, expense, and equity accounts are internal bookkeeping managed via
|
||||||
@@ -504,7 +400,6 @@ export function AccountsPage() {
|
|||||||
if (!VISIBLE_ACCOUNT_TYPES.includes(a.account_type)) return false;
|
if (!VISIBLE_ACCOUNT_TYPES.includes(a.account_type)) return false;
|
||||||
if (search && !a.name.toLowerCase().includes(search.toLowerCase()) && !String(a.account_number).includes(search)) return false;
|
if (search && !a.name.toLowerCase().includes(search.toLowerCase()) && !String(a.account_number).includes(search)) return false;
|
||||||
if (filterType && a.account_type !== filterType) return false;
|
if (filterType && a.account_type !== filterType) return false;
|
||||||
if (filterFund && a.fund_type !== filterFund) return false;
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -548,12 +443,6 @@ export function AccountsPage() {
|
|||||||
return sum + (bal * (rate / 100) / 12);
|
return sum + (bal * (rate / 100) / 12);
|
||||||
}, 0);
|
}, 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 ──
|
// ── Adjust modal: current balance from trial balance ──
|
||||||
const adjustCurrentBalance = adjustingAccount
|
const adjustCurrentBalance = adjustingAccount
|
||||||
? parseFloat(
|
? parseFloat(
|
||||||
@@ -583,9 +472,6 @@ export function AccountsPage() {
|
|||||||
onChange={(e) => setShowArchived(e.currentTarget.checked)}
|
onChange={(e) => setShowArchived(e.currentTarget.checked)}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<Button variant="light" leftSection={<IconCurrencyDollar size={16} />} onClick={handleOpenBulkOB}>
|
|
||||||
Set Opening Balances
|
|
||||||
</Button>
|
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||||
Add Account
|
Add Account
|
||||||
</Button>
|
</Button>
|
||||||
@@ -640,14 +526,6 @@ export function AccountsPage() {
|
|||||||
onChange={setFilterType}
|
onChange={setFilterType}
|
||||||
w={150}
|
w={150}
|
||||||
/>
|
/>
|
||||||
<Select
|
|
||||||
placeholder="Fund"
|
|
||||||
clearable
|
|
||||||
data={['operating', 'reserve']}
|
|
||||||
value={filterFund}
|
|
||||||
onChange={setFilterFund}
|
|
||||||
w={150}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Tabs defaultValue="all">
|
<Tabs defaultValue="all">
|
||||||
@@ -674,7 +552,7 @@ export function AccountsPage() {
|
|||||||
onArchive={archiveMutation.mutate}
|
onArchive={archiveMutation.mutate}
|
||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
onSetOpeningBalance={handleSetOpeningBalance}
|
|
||||||
/>
|
/>
|
||||||
{investments.filter(i => i.is_active).length > 0 && (
|
{investments.filter(i => i.is_active).length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -692,7 +570,7 @@ export function AccountsPage() {
|
|||||||
onArchive={archiveMutation.mutate}
|
onArchive={archiveMutation.mutate}
|
||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
onSetOpeningBalance={handleSetOpeningBalance}
|
|
||||||
/>
|
/>
|
||||||
{operatingInvestments.length > 0 && (
|
{operatingInvestments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -710,7 +588,7 @@ export function AccountsPage() {
|
|||||||
onArchive={archiveMutation.mutate}
|
onArchive={archiveMutation.mutate}
|
||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
onSetOpeningBalance={handleSetOpeningBalance}
|
|
||||||
/>
|
/>
|
||||||
{reserveInvestments.length > 0 && (
|
{reserveInvestments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -728,7 +606,7 @@ export function AccountsPage() {
|
|||||||
onArchive={archiveMutation.mutate}
|
onArchive={archiveMutation.mutate}
|
||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
onSetOpeningBalance={handleSetOpeningBalance}
|
|
||||||
isArchivedView
|
isArchivedView
|
||||||
/>
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
@@ -946,126 +824,6 @@ export function AccountsPage() {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</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 */}
|
{/* Investment Edit Modal */}
|
||||||
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
|
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
|
||||||
{editingInvestment && (
|
{editingInvestment && (
|
||||||
@@ -1150,7 +908,6 @@ function AccountTable({
|
|||||||
onArchive,
|
onArchive,
|
||||||
onSetPrimary,
|
onSetPrimary,
|
||||||
onAdjustBalance,
|
onAdjustBalance,
|
||||||
onSetOpeningBalance,
|
|
||||||
isArchivedView = false,
|
isArchivedView = false,
|
||||||
}: {
|
}: {
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
@@ -1158,7 +915,6 @@ function AccountTable({
|
|||||||
onArchive: (a: Account) => void;
|
onArchive: (a: Account) => void;
|
||||||
onSetPrimary: (id: string) => void;
|
onSetPrimary: (id: string) => void;
|
||||||
onAdjustBalance: (a: Account) => void;
|
onAdjustBalance: (a: Account) => void;
|
||||||
onSetOpeningBalance: (a: Account) => void;
|
|
||||||
isArchivedView?: boolean;
|
isArchivedView?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0);
|
const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0);
|
||||||
@@ -1259,13 +1015,6 @@ function AccountTable({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</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 && (
|
{!a.is_system && (
|
||||||
<Tooltip label="Adjust Balance">
|
<Tooltip label="Adjust Balance">
|
||||||
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
|
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { useState, useRef } from 'react';
|
|||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
|
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
|
||||||
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
|
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
|
||||||
Card, SimpleGrid, Progress,
|
Card, SimpleGrid, Progress, Switch, Tooltip,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { DateInput } from '@mantine/dates';
|
import { DateInput } from '@mantine/dates';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconPlus, IconEdit, IconUpload, IconDownload } from '@tabler/icons-react';
|
import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen } 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';
|
||||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
@@ -41,6 +41,7 @@ interface Project {
|
|||||||
account_id: string;
|
account_id: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
is_funding_locked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FUTURE_YEAR = 9999;
|
const FUTURE_YEAR = 9999;
|
||||||
@@ -151,6 +152,7 @@ export function ProjectsPage() {
|
|||||||
target_year: currentYear,
|
target_year: currentYear,
|
||||||
target_month: 6,
|
target_month: 6,
|
||||||
notes: '',
|
notes: '',
|
||||||
|
is_funding_locked: false,
|
||||||
},
|
},
|
||||||
validate: {
|
validate: {
|
||||||
name: (v) => (v.length > 0 ? null : 'Required'),
|
name: (v) => (v.length > 0 ? null : 'Required'),
|
||||||
@@ -258,6 +260,7 @@ export function ProjectsPage() {
|
|||||||
target_year: p.target_year || currentYear,
|
target_year: p.target_year || currentYear,
|
||||||
target_month: p.target_month || 6,
|
target_month: p.target_month || 6,
|
||||||
notes: p.notes || '',
|
notes: p.notes || '',
|
||||||
|
is_funding_locked: p.is_funding_locked || false,
|
||||||
});
|
});
|
||||||
open();
|
open();
|
||||||
};
|
};
|
||||||
@@ -300,9 +303,16 @@ export function ProjectsPage() {
|
|||||||
const pct = cost > 0 ? (funded / cost) * 100 : 0;
|
const pct = cost > 0 ? (funded / cost) * 100 : 0;
|
||||||
const color = pct >= 70 ? 'green' : pct >= 40 ? 'yellow' : 'red';
|
const color = pct >= 70 ? 'green' : pct >= 40 ? 'yellow' : 'red';
|
||||||
return (
|
return (
|
||||||
|
<Group gap={4} justify="flex-end">
|
||||||
|
{project.is_funding_locked && (
|
||||||
|
<Tooltip label="Funding manually locked">
|
||||||
|
<IconLock size={14} style={{ color: 'var(--mantine-color-blue-5)' }} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Text span c={color} ff="monospace">
|
<Text span c={color} ff="monospace">
|
||||||
{pct.toFixed(0)}%
|
{pct.toFixed(0)}%
|
||||||
</Text>
|
</Text>
|
||||||
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -540,12 +550,18 @@ export function ProjectsPage() {
|
|||||||
{/* Row 5: Conditional reserve fields */}
|
{/* Row 5: Conditional reserve fields */}
|
||||||
{form.values.fund_source === 'reserve' && (
|
{form.values.fund_source === 'reserve' && (
|
||||||
<>
|
<>
|
||||||
|
<Switch
|
||||||
|
label="Lock Funding"
|
||||||
|
description="When locked, the fund balance and percentage you set here will be used as-is instead of being auto-calculated from the reserve balance"
|
||||||
|
{...form.getInputProps('is_funding_locked', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Current Fund Balance"
|
label="Current Fund Balance"
|
||||||
prefix="$"
|
prefix="$"
|
||||||
decimalScale={2}
|
decimalScale={2}
|
||||||
min={0}
|
min={0}
|
||||||
|
disabled={!form.values.is_funding_locked}
|
||||||
{...form.getInputProps('current_fund_balance')}
|
{...form.getInputProps('current_fund_balance')}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
@@ -561,6 +577,7 @@ export function ProjectsPage() {
|
|||||||
decimalScale={1}
|
decimalScale={1}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
|
disabled={!form.values.is_funding_locked}
|
||||||
{...form.getInputProps('funded_percentage')}
|
{...form.getInputProps('funded_percentage')}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface Unit {
|
|||||||
assessment_group_id?: string;
|
assessment_group_id?: string;
|
||||||
assessment_group_name?: string;
|
assessment_group_name?: string;
|
||||||
group_regular_assessment?: string;
|
group_regular_assessment?: string;
|
||||||
|
group_special_assessment?: string;
|
||||||
group_frequency?: string;
|
group_frequency?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +194,7 @@ export function UnitsPage() {
|
|||||||
<Table.Th>Email</Table.Th>
|
<Table.Th>Email</Table.Th>
|
||||||
<Table.Th>Group</Table.Th>
|
<Table.Th>Group</Table.Th>
|
||||||
<Table.Th ta="right">Assessment</Table.Th>
|
<Table.Th ta="right">Assessment</Table.Th>
|
||||||
|
<Table.Th ta="right">Special Assessment</Table.Th>
|
||||||
<Table.Th>Status</Table.Th>
|
<Table.Th>Status</Table.Th>
|
||||||
<Table.Th></Table.Th>
|
<Table.Th></Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
@@ -211,7 +213,15 @@ export function UnitsPage() {
|
|||||||
<Text size="xs" c="dimmed">-</Text>
|
<Text size="xs" c="dimmed">-</Text>
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td ta="right" ff="monospace">${parseFloat(u.monthly_assessment || '0').toFixed(2)}</Table.Td>
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
${parseFloat(u.group_regular_assessment || u.monthly_assessment || '0').toFixed(2)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
{parseFloat(u.group_special_assessment || '0') > 0
|
||||||
|
? `$${parseFloat(u.group_special_assessment || '0').toFixed(2)}`
|
||||||
|
: <Text size="sm" c="dimmed">-</Text>
|
||||||
|
}
|
||||||
|
</Table.Td>
|
||||||
<Table.Td><Badge color={u.status === 'active' ? 'green' : 'gray'} size="sm">{u.status}</Badge></Table.Td>
|
<Table.Td><Badge color={u.status === 'active' ? 'green' : 'gray'} size="sm">{u.status}</Badge></Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
@@ -227,7 +237,7 @@ export function UnitsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No units yet</Text></Table.Td></Table.Tr>}
|
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={9}><Text ta="center" c="dimmed" py="lg">No units yet</Text></Table.Td></Table.Tr>}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user