2 Commits

Author SHA1 Message Date
9d137a40d3 fix: enforce read-only restrictions for viewer role across 5 pages
Audit and fix viewer (read-only) user permissions:
- Dashboard: hide health score refresh buttons
- Accounts: hide investment edit icons
- Invoices: hide Apply Late Fees and Generate Invoices buttons
- Capital Planning: disable drag-and-drop, hide grip handles and edit buttons
- Investment Planning: hide AI Recommendations refresh button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:59:20 -04:00
3bf6b8c6c9 fix: update password when adding existing user to new org
When an existing user was added to a new organization via the member
management UI, the password entered in the form was silently ignored.
This caused the user to be unable to log in with the password they
were given, since the hash in the database was from their original
account creation for a different org.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:49:23 -04:00
12 changed files with 91 additions and 102 deletions

View File

@@ -153,6 +153,14 @@ export class OrganizationsService {
existing.role = data.role; existing.role = data.role;
return this.userOrgRepository.save(existing); return this.userOrgRepository.save(existing);
} }
// Update password for existing user being added to a new org
if (data.password) {
const passwordHash = await bcrypt.hash(data.password, 12);
await dataSource.query(
`UPDATE shared.users SET password_hash = $1 WHERE id = $2`,
[passwordHash, userId],
);
}
} else { } else {
// Create new user // Create new user
const passwordHash = await bcrypt.hash(data.password, 12); const passwordHash = await bcrypt.hash(data.password, 12);

View File

@@ -9,20 +9,5 @@
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<script>
(function(d,t) {
var BASE_URL="https//chat.hoaledger.com";
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=BASE_URL+"/packs/js/sdk.js";
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
window.chatwootSDK.run({
websiteToken: 'K6VXvTtKXvaCMvre4yK85SPb',
baseUrl: BASE_URL
})
}
})(document,"script");
</script>
</body> </body>
</html> </html>

View File

@@ -587,7 +587,7 @@ export function AccountsPage() {
{investments.filter(i => i.is_active).length > 0 && ( {investments.filter(i => i.is_active).length > 0 && (
<> <>
<Divider label="Investment Accounts" labelPosition="center" my="xs" /> <Divider label="Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} /> <InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</> </>
)} )}
</Stack> </Stack>
@@ -605,7 +605,7 @@ export function AccountsPage() {
{operatingInvestments.length > 0 && ( {operatingInvestments.length > 0 && (
<> <>
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" /> <Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} /> <InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</> </>
)} )}
</Stack> </Stack>
@@ -623,7 +623,7 @@ export function AccountsPage() {
{reserveInvestments.length > 0 && ( {reserveInvestments.length > 0 && (
<> <>
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" /> <Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} /> <InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</> </>
)} )}
</Stack> </Stack>
@@ -1087,9 +1087,11 @@ function AccountTable({
function InvestmentMiniTable({ function InvestmentMiniTable({
investments, investments,
onEdit, onEdit,
isReadOnly = false,
}: { }: {
investments: Investment[]; investments: Investment[];
onEdit: (inv: Investment) => void; onEdit: (inv: Investment) => void;
isReadOnly?: boolean;
}) { }) {
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0); const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
const totalValue = investments.reduce( const totalValue = investments.reduce(
@@ -1132,7 +1134,7 @@ function InvestmentMiniTable({
<Table.Th ta="right">Maturity Value</Table.Th> <Table.Th ta="right">Maturity Value</Table.Th>
<Table.Th>Maturity Date</Table.Th> <Table.Th>Maturity Date</Table.Th>
<Table.Th ta="right">Days Remaining</Table.Th> <Table.Th ta="right">Days Remaining</Table.Th>
<Table.Th></Table.Th> {!isReadOnly && <Table.Th></Table.Th>}
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
@@ -1182,6 +1184,7 @@ function InvestmentMiniTable({
'-' '-'
)} )}
</Table.Td> </Table.Td>
{!isReadOnly && (
<Table.Td> <Table.Td>
<Tooltip label="Edit investment"> <Tooltip label="Edit investment">
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}> <ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
@@ -1189,6 +1192,7 @@ function InvestmentMiniTable({
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Table.Td> </Table.Td>
)}
</Table.Tr> </Table.Tr>
))} ))}
</Table.Tbody> </Table.Tbody>

View File

@@ -8,7 +8,6 @@ import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } from '@tab
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 { useIsReadOnly } from '../../stores/authStore'; import { useIsReadOnly } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetLine { interface BudgetLine {
account_id: string; account_id: string;
@@ -99,11 +98,6 @@ export function BudgetsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly(); const isReadOnly = useIsReadOnly();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
const { isLoading } = useQuery<BudgetLine[]>({ const { isLoading } = useQuery<BudgetLine[]>({
queryKey: ['budgets', year], queryKey: ['budgets', year],
@@ -323,8 +317,8 @@ export function BudgetsPage() {
<Table striped highlightOnHover style={{ minWidth: 1600 }}> <Table striped highlightOnHover style={{ minWidth: 1600 }}>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>Acct #</Table.Th> <Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>Account Name</Table.Th> <Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
{monthLabels.map((m) => ( {monthLabels.map((m) => (
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th> <Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
))} ))}
@@ -343,7 +337,7 @@ export function BudgetsPage() {
const lines = budgetData.filter((b) => b.account_type === type); const lines = budgetData.filter((b) => b.account_type === type);
if (lines.length === 0) return null; if (lines.length === 0) return null;
const sectionBg = type === 'income' ? incomeSectionBg : expenseSectionBg; const sectionBg = type === 'income' ? '#e6f9e6' : '#fde8e8';
const sectionTotal = lines.reduce((sum, line) => sum + (line.annual_total || 0), 0); const sectionTotal = lines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
return [ return [
@@ -374,9 +368,9 @@ export function BudgetsPage() {
style={{ style={{
position: 'sticky', position: 'sticky',
left: 0, left: 0,
background: stickyBg, background: 'white',
zIndex: 1, zIndex: 1,
borderRight: `1px solid ${stickyBorder}`, borderRight: '1px solid #e9ecef',
}} }}
> >
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text> <Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
@@ -385,9 +379,9 @@ export function BudgetsPage() {
style={{ style={{
position: 'sticky', position: 'sticky',
left: 120, left: 120,
background: stickyBg, background: 'white',
zIndex: 1, zIndex: 1,
borderRight: `1px solid ${stickyBorder}`, borderRight: '1px solid #e9ecef',
}} }}
> >
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">

View File

@@ -72,9 +72,10 @@ interface KanbanCardProps {
project: Project; project: Project;
onEdit: (p: Project) => void; onEdit: (p: Project) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void; onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
isReadOnly?: boolean;
} }
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) { function KanbanCard({ project, onEdit, onDragStart, isReadOnly }: KanbanCardProps) {
const plannedLabel = formatPlannedDate(project.planned_date); const plannedLabel = formatPlannedDate(project.planned_date);
// For projects in the Future bucket with a specific year, show the year // For projects in the Future bucket with a specific year, show the year
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
@@ -86,21 +87,23 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
padding="sm" padding="sm"
radius="md" radius="md"
withBorder withBorder
draggable draggable={!isReadOnly}
onDragStart={(e) => onDragStart(e, project)} onDragStart={!isReadOnly ? (e) => onDragStart(e, project) : undefined}
style={{ cursor: 'grab', userSelect: 'none' }} style={{ cursor: isReadOnly ? 'default' : 'grab', userSelect: 'none' }}
mb="xs" mb="xs"
> >
<Group justify="space-between" wrap="nowrap" mb={4}> <Group justify="space-between" wrap="nowrap" mb={4}>
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}> <Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
<IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} /> {!isReadOnly && <IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />}
<Text fw={600} size="sm" truncate> <Text fw={600} size="sm" truncate>
{project.name} {project.name}
</Text> </Text>
</Group> </Group>
{!isReadOnly && (
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}> <ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
<IconEdit size={14} /> <IconEdit size={14} />
</ActionIcon> </ActionIcon>
)}
</Group> </Group>
<Group gap={6} mb={6}> <Group gap={6} mb={6}>
@@ -148,11 +151,12 @@ interface KanbanColumnProps {
isDragOver: boolean; isDragOver: boolean;
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void; onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
onDragLeave: () => void; onDragLeave: () => void;
isReadOnly?: boolean;
} }
function KanbanColumn({ function KanbanColumn({
year, projects, onEdit, onDragStart, onDrop, year, projects, onEdit, onDragStart, onDrop,
isDragOver, onDragOverHandler, onDragLeave, isDragOver, onDragOverHandler, onDragLeave, isReadOnly,
}: KanbanColumnProps) { }: KanbanColumnProps) {
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0); const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
const isFuture = year === FUTURE_YEAR; const isFuture = year === FUTURE_YEAR;
@@ -178,9 +182,9 @@ function KanbanColumn({
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined, border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
transition: 'background-color 150ms ease, border 150ms ease', transition: 'background-color 150ms ease, border 150ms ease',
}} }}
onDragOver={(e) => onDragOverHandler(e, year)} onDragOver={!isReadOnly ? (e) => onDragOverHandler(e, year) : undefined}
onDragLeave={onDragLeave} onDragLeave={!isReadOnly ? onDragLeave : undefined}
onDrop={(e) => onDrop(e, year)} onDrop={!isReadOnly ? (e) => onDrop(e, year) : undefined}
> >
<Group justify="space-between" mb="sm"> <Group justify="space-between" mb="sm">
<Title order={5}>{yearLabel(year)}</Title> <Title order={5}>{yearLabel(year)}</Title>
@@ -199,7 +203,7 @@ function KanbanColumn({
<Box style={{ flex: 1, minHeight: 60 }}> <Box style={{ flex: 1, minHeight: 60 }}>
{projects.length === 0 ? ( {projects.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="lg"> <Text size="xs" c="dimmed" ta="center" py="lg">
Drop projects here {isReadOnly ? 'No projects' : 'Drop projects here'}
</Text> </Text>
) : useWideLayout ? ( ) : useWideLayout ? (
<div style={{ <div style={{
@@ -208,12 +212,12 @@ function KanbanColumn({
gap: 'var(--mantine-spacing-xs)', gap: 'var(--mantine-spacing-xs)',
}}> }}>
{projects.map((p) => ( {projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} /> <KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
))} ))}
</div> </div>
) : ( ) : (
projects.map((p) => ( projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} /> <KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
)) ))
)} )}
</Box> </Box>
@@ -595,6 +599,7 @@ export function CapitalProjectsPage() {
isDragOver={dragOverYear === year} isDragOver={dragOverYear === year}
onDragOverHandler={handleDragOver} onDragOverHandler={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
isReadOnly={isReadOnly}
/> />
); );
})} })}

View File

@@ -8,7 +8,6 @@ import {
IconArrowLeft, IconArrowRight, IconCalendar, IconArrowLeft, IconArrowRight, IconCalendar,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, AreaChart, Area, XAxis, YAxis, CartesianGrid,
Tooltip as RechartsTooltip, ResponsiveContainer, Legend, Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
@@ -80,7 +79,6 @@ export function CashFlowForecastPage() {
const now = new Date(); const now = new Date();
const currentYear = now.getFullYear(); const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1; const currentMonth = now.getMonth() + 1;
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
// Filter: All, Operating, Reserve // Filter: All, Operating, Reserve
const [fundFilter, setFundFilter] = useState<string>('all'); const [fundFilter, setFundFilter] = useState<string>('all');
@@ -420,10 +418,10 @@ export function CashFlowForecastPage() {
<tr <tr
key={d.month} key={d.month}
style={{ style={{
borderBottom: `1px solid ${isDark ? 'var(--mantine-color-dark-4)' : 'var(--mantine-color-gray-2)'}`, borderBottom: '1px solid var(--mantine-color-gray-2)',
backgroundColor: d.is_forecast backgroundColor: d.is_forecast
? (isDark ? 'var(--mantine-color-orange-9)' : 'var(--mantine-color-orange-0)') ? 'var(--mantine-color-orange-0)'
: i % 2 === 0 ? 'transparent' : (isDark ? 'var(--mantine-color-dark-5)' : 'var(--mantine-color-gray-0)'), : i % 2 === 0 ? 'transparent' : 'var(--mantine-color-gray-0)',
}} }}
> >
<td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td> <td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td>

View File

@@ -18,7 +18,7 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
import api from '../../services/api'; import api from '../../services/api';
interface HealthScore { interface HealthScore {
@@ -311,6 +311,7 @@ interface DashboardData {
export function DashboardPage() { export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg); const currentOrg = useAuthStore((s) => s.currentOrg);
const isReadOnly = useIsReadOnly();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Track whether a refresh is in progress (per score type) for async polling // Track whether a refresh is in progress (per score type) for async polling
@@ -424,7 +425,7 @@ export function DashboardPage() {
</ThemeIcon> </ThemeIcon>
} }
isRefreshing={operatingRefreshing} isRefreshing={operatingRefreshing}
onRefresh={handleRefreshOperating} onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
lastFailed={!!healthScores?.operating_last_failed} lastFailed={!!healthScores?.operating_last_failed}
/> />
<HealthScoreCard <HealthScoreCard
@@ -436,7 +437,7 @@ export function DashboardPage() {
</ThemeIcon> </ThemeIcon>
} }
isRefreshing={reserveRefreshing} isRefreshing={reserveRefreshing}
onRefresh={handleRefreshReserve} onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
lastFailed={!!healthScores?.reserve_last_failed} lastFailed={!!healthScores?.reserve_last_failed}
/> />
</SimpleGrid> </SimpleGrid>

View File

@@ -36,6 +36,7 @@ import {
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
// ── Types ── // ── Types ──
@@ -347,6 +348,7 @@ function RecommendationsDisplay({
export function InvestmentPlanningPage() { export function InvestmentPlanningPage() {
const [ratesExpanded, setRatesExpanded] = useState(true); const [ratesExpanded, setRatesExpanded] = useState(true);
const [isTriggering, setIsTriggering] = useState(false); const [isTriggering, setIsTriggering] = useState(false);
const isReadOnly = useIsReadOnly();
// Load financial snapshot on mount // Load financial snapshot on mount
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({ const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
@@ -696,6 +698,7 @@ export function InvestmentPlanningPage() {
</Text> </Text>
</div> </div>
</Group> </Group>
{!isReadOnly && (
<Button <Button
leftSection={<IconSparkles size={16} />} leftSection={<IconSparkles size={16} />}
onClick={handleTriggerAI} onClick={handleTriggerAI}
@@ -705,6 +708,7 @@ export function InvestmentPlanningPage() {
> >
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'} {aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
</Button> </Button>
)}
</Group> </Group>
{/* Processing State */} {/* Processing State */}

View File

@@ -9,6 +9,7 @@ import { notifications } from '@mantine/notifications';
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react'; import { IconSend, IconInfoCircle, IconCheck, IconX } 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 { useIsReadOnly } from '../../stores/authStore';
interface Invoice { interface Invoice {
id: string; invoice_number: string; unit_number: string; unit_id: string; id: string; invoice_number: string; unit_number: string; unit_id: string;
@@ -64,6 +65,7 @@ export function InvoicesPage() {
const [preview, setPreview] = useState<Preview | null>(null); const [preview, setPreview] = useState<Preview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({ const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'], queryKey: ['invoices'],
@@ -124,10 +126,12 @@ export function InvoicesPage() {
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Title order={2}>Invoices</Title> <Title order={2}>Invoices</Title>
{!isReadOnly && (
<Group> <Group>
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button> <Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button> <Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
</Group> </Group>
)}
</Group> </Group>
<Group> <Group>
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card> <Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>

View File

@@ -10,7 +10,6 @@ import {
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 { useIsReadOnly } from '../../stores/authStore'; import { useIsReadOnly } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel'; import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
interface ActualLine { interface ActualLine {
@@ -67,11 +66,6 @@ export function MonthlyActualsPage() {
const [savedJEId, setSavedJEId] = useState<string | null>(null); const [savedJEId, setSavedJEId] = useState<string | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly(); const isReadOnly = useIsReadOnly();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
const yearOptions = Array.from({ length: 5 }, (_, i) => { const yearOptions = Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i; const y = new Date().getFullYear() - 2 + i;
@@ -184,16 +178,16 @@ export function MonthlyActualsPage() {
<Table.Tr key={line.account_id}> <Table.Tr key={line.account_id}>
<Table.Td <Table.Td
style={{ style={{
position: 'sticky', left: 0, background: stickyBg, zIndex: 1, position: 'sticky', left: 0, background: 'white', zIndex: 1,
borderRight: `1px solid ${stickyBorder}`, borderRight: '1px solid #e9ecef',
}} }}
> >
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text> <Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
</Table.Td> </Table.Td>
<Table.Td <Table.Td
style={{ style={{
position: 'sticky', left: 120, background: stickyBg, zIndex: 1, position: 'sticky', left: 120, background: 'white', zIndex: 1,
borderRight: `1px solid ${stickyBorder}`, borderRight: '1px solid #e9ecef',
}} }}
> >
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
@@ -298,10 +292,10 @@ export function MonthlyActualsPage() {
<Table striped highlightOnHover style={{ minWidth: 700 }}> <Table striped highlightOnHover style={{ minWidth: 700 }}>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}> <Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}>
Acct # Acct #
</Table.Th> </Table.Th>
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}> <Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>
Account Name Account Name
</Table.Th> </Table.Th>
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th> <Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
@@ -310,8 +304,8 @@ export function MonthlyActualsPage() {
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{renderSection('Income', incomeLines, incomeBg, totals.incomeBudget, totals.incomeActual)} {renderSection('Income', incomeLines, '#e6f9e6', totals.incomeBudget, totals.incomeActual)}
{renderSection('Expenses', expenseLines, expenseBg, totals.expenseBudget, totals.expenseActual)} {renderSection('Expenses', expenseLines, '#fde8e8', totals.expenseBudget, totals.expenseActual)}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</div> </div>

View File

@@ -5,7 +5,6 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetVsActualLine { interface BudgetVsActualLine {
account_id: string; account_id: string;
@@ -47,9 +46,6 @@ const monthFilterOptions = [
export function BudgetVsActualPage() { export function BudgetVsActualPage() {
const [year, setYear] = useState(new Date().getFullYear().toString()); const [year, setYear] = useState(new Date().getFullYear().toString());
const [month, setMonth] = useState(''); const [month, setMonth] = useState('');
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
const yearOptions = Array.from({ length: 5 }, (_, i) => { const yearOptions = Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i; const y = new Date().getFullYear() - 2 + i;
@@ -96,7 +92,7 @@ export function BudgetVsActualPage() {
const renderSection = (title: string, sectionLines: BudgetVsActualLine[], isExpense: boolean, totalBudget: number, totalActual: number) => ( const renderSection = (title: string, sectionLines: BudgetVsActualLine[], isExpense: boolean, totalBudget: number, totalActual: number) => (
<> <>
<Table.Tr style={{ background: isExpense ? expenseBg : incomeBg }}> <Table.Tr style={{ background: isExpense ? '#fde8e8' : '#e6f9e6' }}>
<Table.Td colSpan={6} fw={700}>{title}</Table.Td> <Table.Td colSpan={6} fw={700}>{title}</Table.Td>
</Table.Tr> </Table.Tr>
{sectionLines.map((line) => { {sectionLines.map((line) => {

View File

@@ -8,7 +8,6 @@ import {
IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar, IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import api from '../../services/api'; import api from '../../services/api';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetVsActualItem { interface BudgetVsActualItem {
account_id: string; account_id: string;
@@ -49,9 +48,6 @@ export function QuarterlyReportPage() {
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3); const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
const defaultQuarter = currentQuarter; const defaultQuarter = currentQuarter;
const defaultYear = now.getFullYear(); const defaultYear = now.getFullYear();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
const [year, setYear] = useState(String(defaultYear)); const [year, setYear] = useState(String(defaultYear));
const [quarter, setQuarter] = useState(String(defaultQuarter)); const [quarter, setQuarter] = useState(String(defaultQuarter));
@@ -211,7 +207,7 @@ export function QuarterlyReportPage() {
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{incomeItems.length > 0 && ( {incomeItems.length > 0 && (
<Table.Tr style={{ background: incomeBg }}> <Table.Tr style={{ background: '#e6f9e6' }}>
<Table.Td colSpan={8} fw={700}>Income</Table.Td> <Table.Td colSpan={8} fw={700}>Income</Table.Td>
</Table.Tr> </Table.Tr>
)} )}
@@ -219,7 +215,7 @@ export function QuarterlyReportPage() {
<BVARow key={item.account_id} item={item} isExpense={false} /> <BVARow key={item.account_id} item={item} isExpense={false} />
))} ))}
{incomeItems.length > 0 && ( {incomeItems.length > 0 && (
<Table.Tr style={{ background: incomeBg }}> <Table.Tr style={{ background: '#e6f9e6' }}>
<Table.Td colSpan={2} fw={700}>Total Income</Table.Td> <Table.Td colSpan={2} fw={700}>Total Income</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td> <Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td> <Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
@@ -230,7 +226,7 @@ export function QuarterlyReportPage() {
</Table.Tr> </Table.Tr>
)} )}
{expenseItems.length > 0 && ( {expenseItems.length > 0 && (
<Table.Tr style={{ background: expenseBg }}> <Table.Tr style={{ background: '#fde8e8' }}>
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td> <Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
</Table.Tr> </Table.Tr>
)} )}
@@ -238,7 +234,7 @@ export function QuarterlyReportPage() {
<BVARow key={item.account_id} item={item} isExpense={true} /> <BVARow key={item.account_id} item={item} isExpense={true} />
))} ))}
{expenseItems.length > 0 && ( {expenseItems.length > 0 && (
<Table.Tr style={{ background: expenseBg }}> <Table.Tr style={{ background: '#fde8e8' }}>
<Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td> <Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td> <Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td> <Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>