RBAC: Enforce read-only viewer role across backend and frontend

- Add global WriteAccessGuard that blocks POST/PUT/PATCH/DELETE for viewer role
- Add @AllowViewer() decorator for endpoints viewers need (switch-org, intro-seen, AI recommendations)
- Add useIsReadOnly hook to auth store for frontend role checks
- Hide write UI (add/edit/delete/import buttons, inline editors) in all 13 data pages for viewers
- Disable inline NumberInputs on Budgets and Monthly Actuals pages for viewers
- Skip onboarding wizard for viewer role users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 09:18:32 -05:00
parent 07347a644f
commit c92eb1b57b
20 changed files with 269 additions and 156 deletions

View File

@@ -1,9 +1,11 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module'; import { DatabaseModule } from './database/database.module';
import { TenantMiddleware } from './database/tenant.middleware'; import { TenantMiddleware } from './database/tenant.middleware';
import { WriteAccessGuard } from './common/guards/write-access.guard';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { OrganizationsModule } from './modules/organizations/organizations.module'; import { OrganizationsModule } from './modules/organizations/organizations.module';
import { UsersModule } from './modules/users/users.module'; import { UsersModule } from './modules/users/users.module';
@@ -64,6 +66,12 @@ import { InvestmentPlanningModule } from './modules/investment-planning/investme
InvestmentPlanningModule, InvestmentPlanningModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [
{
provide: APP_GUARD,
useClass: WriteAccessGuard,
},
],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const ALLOW_VIEWER_KEY = 'allowViewer';
export const AllowViewer = () => SetMetadata(ALLOW_VIEWER_KEY, true);

View File

@@ -0,0 +1,34 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ALLOW_VIEWER_KEY } from '../decorators/allow-viewer.decorator';
@Injectable()
export class WriteAccessGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const method = request.method;
// Allow all read methods
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return true;
// If no user on request (unauthenticated endpoints like login/register), allow
const user = request.user;
if (!user) return true;
// Check for @AllowViewer() exemption on handler or class
const allowViewer = this.reflector.getAllAndOverride<boolean>(ALLOW_VIEWER_KEY, [
context.getHandler(),
context.getClass(),
]);
if (allowViewer) return true;
// Block viewer role from write operations
if (user.role === 'viewer') {
throw new ForbiddenException('Read-only users cannot modify data');
}
return true;
}
}

View File

@@ -14,6 +14,7 @@ import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { SwitchOrgDto } from './dto/switch-org.dto'; import { SwitchOrgDto } from './dto/switch-org.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
@ApiTags('auth') @ApiTags('auth')
@Controller('auth') @Controller('auth')
@@ -47,6 +48,7 @@ export class AuthController {
@ApiOperation({ summary: 'Mark the how-to intro as seen for the current user' }) @ApiOperation({ summary: 'Mark the how-to intro as seen for the current user' })
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@AllowViewer()
async markIntroSeen(@Request() req: any) { async markIntroSeen(@Request() req: any) {
await this.authService.markIntroSeen(req.user.sub); await this.authService.markIntroSeen(req.user.sub);
return { success: true }; return { success: true };
@@ -56,6 +58,7 @@ export class AuthController {
@ApiOperation({ summary: 'Switch active organization' }) @ApiOperation({ summary: 'Switch active organization' })
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@AllowViewer()
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) { async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
const ip = req.headers['x-forwarded-for'] || req.ip; const ip = req.headers['x-forwarded-for'] || req.ip;
const ua = req.headers['user-agent']; const ua = req.headers['user-agent'];

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common'; import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { InvestmentPlanningService } from './investment-planning.service'; import { InvestmentPlanningService } from './investment-planning.service';
@ApiTags('investment-planning') @ApiTags('investment-planning')
@@ -36,6 +37,7 @@ export class InvestmentPlanningController {
@Post('recommendations') @Post('recommendations')
@ApiOperation({ summary: 'Get AI-powered investment recommendations' }) @ApiOperation({ summary: 'Get AI-powered investment recommendations' })
@AllowViewer()
getRecommendations(@Req() req: any) { getRecommendations(@Req() req: any) {
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId); return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId);
} }

View File

@@ -32,6 +32,8 @@ export function AppLayout() {
// Only run for non-impersonating users with an org selected, on dashboard // Only run for non-impersonating users with an org selected, on dashboard
if (isImpersonating || !currentOrg || !user) return; if (isImpersonating || !currentOrg || !user) return;
if (!location.pathname.startsWith('/dashboard')) return; if (!location.pathname.startsWith('/dashboard')) return;
// Read-only users (viewers) skip onboarding entirely
if (currentOrg.role === 'viewer') return;
if (user.hasSeenIntro === false || user.hasSeenIntro === undefined) { if (user.hasSeenIntro === false || user.hasSeenIntro === undefined) {
// Delay to ensure DOM elements are rendered for tour targeting // Delay to ensure DOM elements are rendered for tour targeting
@@ -40,7 +42,7 @@ export function AppLayout() {
} else if (currentOrg.settings?.onboardingComplete !== true) { } else if (currentOrg.settings?.onboardingComplete !== true) {
setShowWizard(true); setShowWizard(true);
} }
}, [user?.hasSeenIntro, currentOrg?.id, currentOrg?.settings?.onboardingComplete, isImpersonating, location.pathname]); }, [user?.hasSeenIntro, currentOrg?.id, currentOrg?.role, currentOrg?.settings?.onboardingComplete, isImpersonating, location.pathname]);
const handleTourComplete = () => { const handleTourComplete = () => {
setShowTour(false); setShowTour(false);

View File

@@ -40,6 +40,7 @@ import {
} 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';
import { useIsReadOnly } from '../../stores/authStore';
const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage']; const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage'];
@@ -126,6 +127,7 @@ export function AccountsPage() {
const [filterType, setFilterType] = useState<string | null>(null); const [filterType, setFilterType] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
// ── Accounts query ── // ── Accounts query ──
const { data: accounts = [], isLoading } = useQuery<Account[]>({ const { data: accounts = [], isLoading } = useQuery<Account[]>({
@@ -502,9 +504,11 @@ export function AccountsPage() {
onChange={(e) => setShowArchived(e.currentTarget.checked)} onChange={(e) => setShowArchived(e.currentTarget.checked)}
size="sm" size="sm"
/> />
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}> {!isReadOnly && (
Add Account <Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
</Button> Add Account
</Button>
)}
</Group> </Group>
</Group> </Group>
@@ -578,7 +582,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate} onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance} onAdjustBalance={handleAdjustBalance}
isReadOnly={isReadOnly}
/> />
{investments.filter(i => i.is_active).length > 0 && ( {investments.filter(i => i.is_active).length > 0 && (
<> <>
@@ -596,7 +600,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate} onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance} onAdjustBalance={handleAdjustBalance}
isReadOnly={isReadOnly}
/> />
{operatingInvestments.length > 0 && ( {operatingInvestments.length > 0 && (
<> <>
@@ -614,7 +618,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate} onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance} onAdjustBalance={handleAdjustBalance}
isReadOnly={isReadOnly}
/> />
{reserveInvestments.length > 0 && ( {reserveInvestments.length > 0 && (
<> <>
@@ -632,7 +636,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate} onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)} onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance} onAdjustBalance={handleAdjustBalance}
isReadOnly={isReadOnly}
isArchivedView isArchivedView
/> />
</Tabs.Panel> </Tabs.Panel>
@@ -934,6 +938,7 @@ function AccountTable({
onArchive, onArchive,
onSetPrimary, onSetPrimary,
onAdjustBalance, onAdjustBalance,
isReadOnly = false,
isArchivedView = false, isArchivedView = false,
}: { }: {
accounts: Account[]; accounts: Account[];
@@ -941,6 +946,7 @@ 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;
isReadOnly?: boolean;
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);
@@ -1029,42 +1035,44 @@ function AccountTable({
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''} {a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap={4}> {!isReadOnly && (
{!a.is_system && ( <Group gap={4}>
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}> {!a.is_system && (
<ActionIcon <Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
variant="subtle" <ActionIcon
color="yellow" variant="subtle"
onClick={() => onSetPrimary(a.id)} color="yellow"
> onClick={() => onSetPrimary(a.id)}
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />} >
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar 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> </ActionIcon>
</Tooltip> </Tooltip>
)} {!a.is_system && (
{!a.is_system && ( <Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
<Tooltip label="Adjust Balance"> <ActionIcon
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}> variant="subtle"
<IconAdjustments size={16} /> color={a.is_active ? 'gray' : 'green'}
</ActionIcon> onClick={() => onArchive(a)}
</Tooltip> >
)} {a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
<Tooltip label="Edit account"> </ActionIcon>
<ActionIcon variant="subtle" onClick={() => onEdit(a)}> </Tooltip>
<IconEdit size={16} /> )}
</ActionIcon> </Group>
</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.Td>
</Table.Tr> </Table.Tr>
); );

View File

@@ -11,6 +11,7 @@ import {
} 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';
import { useIsReadOnly } from '../../stores/authStore';
interface AssessmentGroup { interface AssessmentGroup {
id: string; id: string;
@@ -52,6 +53,7 @@ export function AssessmentGroupsPage() {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<AssessmentGroup | null>(null); const [editing, setEditing] = useState<AssessmentGroup | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({ const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
queryKey: ['assessment-groups'], queryKey: ['assessment-groups'],
@@ -156,9 +158,11 @@ export function AssessmentGroupsPage() {
<Title order={2}>Assessment Groups</Title> <Title order={2}>Assessment Groups</Title>
<Text c="dimmed" size="sm">Manage property types with different assessment rates and frequencies</Text> <Text c="dimmed" size="sm">Manage property types with different assessment rates and frequencies</Text>
</div> </div>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}> {!isReadOnly && (
Add Group <Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
</Button> Add Group
</Button>
)}
</Group> </Group>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}> <SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
@@ -274,28 +278,30 @@ export function AssessmentGroupsPage() {
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap={4}> {!isReadOnly && (
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}> <Group gap={4}>
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
<ActionIcon
variant="subtle"
color={g.is_default ? 'yellow' : 'gray'}
onClick={() => !g.is_default && setDefaultMutation.mutate(g.id)}
disabled={g.is_default}
>
{g.is_default ? <IconStarFilled size={16} /> : <IconStar size={16} />}
</ActionIcon>
</Tooltip>
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color={g.is_default ? 'yellow' : 'gray'} color={g.is_active ? 'gray' : 'green'}
onClick={() => !g.is_default && setDefaultMutation.mutate(g.id)} onClick={() => archiveMutation.mutate(g)}
disabled={g.is_default}
> >
{g.is_default ? <IconStarFilled size={16} /> : <IconStar size={16} />} <IconArchive size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Group>
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}> )}
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color={g.is_active ? 'gray' : 'green'}
onClick={() => archiveMutation.mutate(g)}
>
<IconArchive size={16} />
</ActionIcon>
</Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}

View File

@@ -7,6 +7,7 @@ import { notifications } from '@mantine/notifications';
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } from '@tabler/icons-react'; import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } 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 BudgetLine { interface BudgetLine {
account_id: string; account_id: string;
@@ -96,6 +97,7 @@ export function BudgetsPage() {
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]); const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
const { isLoading } = useQuery<BudgetLine[]>({ const { isLoading } = useQuery<BudgetLine[]>({
queryKey: ['budgets', year], queryKey: ['budgets', year],
@@ -257,24 +259,26 @@ export function BudgetsPage() {
> >
Download Template Download Template
</Button> </Button>
<Button {!isReadOnly && (<>
variant="outline" <Button
leftSection={<IconUpload size={16} />} variant="outline"
onClick={handleImportCSV} leftSection={<IconUpload size={16} />}
loading={importMutation.isPending} onClick={handleImportCSV}
> loading={importMutation.isPending}
Import CSV >
</Button> Import CSV
<input </Button>
type="file" <input
ref={fileInputRef} type="file"
style={{ display: 'none' }} ref={fileInputRef}
accept=".csv,.txt" style={{ display: 'none' }}
onChange={handleFileChange} accept=".csv,.txt"
/> onChange={handleFileChange}
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}> />
Save Budget <Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
</Button> Save Budget
</Button>
</>)}
</Group> </Group>
</Group> </Group>
@@ -394,6 +398,7 @@ export function BudgetsPage() {
hideControls hideControls
decimalScale={2} decimalScale={2}
min={0} min={0}
disabled={isReadOnly}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }} styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/> />
</Table.Td> </Table.Td>

View File

@@ -14,6 +14,7 @@ import {
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
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';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types & constants // Types & constants
@@ -215,6 +216,7 @@ export function CapitalProjectsPage() {
const [dragOverYear, setDragOverYear] = useState<number | null>(null); const [dragOverYear, setDragOverYear] = useState<number | null>(null);
const printModeRef = useRef(false); const printModeRef = useRef(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
// ---- Data fetching ---- // ---- Data fetching ----
@@ -511,9 +513,9 @@ export function CapitalProjectsPage() {
</Table.Td> </Table.Td>
<Table.Td>{formatPlannedDate(p.planned_date) || '-'}</Table.Td> <Table.Td>{formatPlannedDate(p.planned_date) || '-'}</Table.Td>
<Table.Td> <Table.Td>
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}> {!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} /> <IconEdit size={16} />
</ActionIcon> </ActionIcon>}
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}

View File

@@ -10,6 +10,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react'; import { IconPlus, IconEdit } 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 Investment { interface Investment {
id: string; name: string; institution: string; account_number_last4: string; id: string; name: string; institution: string; account_number_last4: string;
@@ -25,6 +26,7 @@ export function InvestmentsPage() {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Investment | null>(null); const [editing, setEditing] = useState<Investment | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: investments = [], isLoading } = useQuery<Investment[]>({ const { data: investments = [], isLoading } = useQuery<Investment[]>({
queryKey: ['investments'], queryKey: ['investments'],
@@ -95,7 +97,7 @@ export function InvestmentsPage() {
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Title order={2}>Investment Accounts</Title> <Title order={2}>Investment Accounts</Title>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button> {!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button>}
</Group> </Group>
<SimpleGrid cols={{ base: 1, sm: 3, lg: 5 }}> <SimpleGrid cols={{ base: 1, sm: 3, lg: 5 }}>
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Principal</Text><Text fw={700} size="xl">{fmt(totalPrincipal)}</Text></Card> <Card withBorder p="md"><Text size="xs" c="dimmed">Total Principal</Text><Text fw={700} size="xl">{fmt(totalPrincipal)}</Text></Card>
@@ -139,7 +141,7 @@ export function InvestmentsPage() {
) : '-'} ) : '-'}
</Table.Td> </Table.Td>
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</Table.Td> <Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(inv)}><IconEdit size={16} /></ActionIcon></Table.Td> <Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(inv)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
{investments.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No investments yet</Text></Table.Td></Table.Tr>} {investments.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No investments yet</Text></Table.Td></Table.Tr>}

View File

@@ -9,6 +9,7 @@ import {
} 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';
import { useIsReadOnly } from '../../stores/authStore';
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel'; import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
interface ActualLine { interface ActualLine {
@@ -64,6 +65,7 @@ export function MonthlyActualsPage() {
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({}); const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
const [savedJEId, setSavedJEId] = useState<string | null>(null); const [savedJEId, setSavedJEId] = useState<string | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
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;
@@ -204,6 +206,7 @@ export function MonthlyActualsPage() {
hideControls hideControls
decimalScale={2} decimalScale={2}
allowNegative allowNegative
disabled={isReadOnly}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }} styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/> />
</Table.Td> </Table.Td>
@@ -229,14 +232,16 @@ export function MonthlyActualsPage() {
<Group> <Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} /> <Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} /> <Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
<Button {!isReadOnly && (
leftSection={<IconDeviceFloppy size={16} />} <Button
onClick={() => saveMutation.mutate()} leftSection={<IconDeviceFloppy size={16} />}
loading={saveMutation.isPending} onClick={() => saveMutation.mutate()}
disabled={lines.length === 0} loading={saveMutation.isPending}
> disabled={lines.length === 0}
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'} >
</Button> {hasChanges ? 'Save & Reconcile' : 'Save Actuals'}
</Button>
)}
</Group> </Group>
</Group> </Group>

View File

@@ -13,7 +13,7 @@ import {
} 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';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
interface OrgMember { interface OrgMember {
id: string; id: string;
@@ -52,6 +52,7 @@ export function OrgMembersPage() {
const [editingMember, setEditingMember] = useState<OrgMember | null>(null); const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { user, currentOrg } = useAuthStore(); const { user, currentOrg } = useAuthStore();
const isReadOnly = useIsReadOnly();
const { data: members = [], isLoading } = useQuery<OrgMember[]>({ const { data: members = [], isLoading } = useQuery<OrgMember[]>({
queryKey: ['org-members'], queryKey: ['org-members'],
@@ -162,9 +163,11 @@ export function OrgMembersPage() {
<Title order={2}>Organization Members</Title> <Title order={2}>Organization Members</Title>
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text> <Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
</div> </div>
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}> {!isReadOnly && (
Add Member <Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
</Button> Add Member
</Button>
)}
</Group> </Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}> <SimpleGrid cols={{ base: 1, sm: 3 }}>
@@ -259,20 +262,22 @@ export function OrgMembersPage() {
{member.lastLoginAt ? new Date(member.lastLoginAt).toLocaleDateString() : 'Never'} {member.lastLoginAt ? new Date(member.lastLoginAt).toLocaleDateString() : 'Never'}
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap={4}> {!isReadOnly && (
<Tooltip label="Change role"> <Group gap={4}>
<ActionIcon variant="subtle" onClick={() => handleEditRole(member)}> <Tooltip label="Change role">
<IconEdit size={16} /> <ActionIcon variant="subtle" onClick={() => handleEditRole(member)}>
</ActionIcon> <IconEdit size={16} />
</Tooltip>
{member.userId !== user?.id && (
<Tooltip label="Remove member">
<ActionIcon variant="subtle" color="red" onClick={() => handleRemove(member)}>
<IconTrash size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} {member.userId !== user?.id && (
</Group> <Tooltip label="Remove member">
<ActionIcon variant="subtle" color="red" onClick={() => handleRemove(member)}>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
)}
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}

View File

@@ -10,6 +10,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus } from '@tabler/icons-react'; import { IconPlus } 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 Payment { interface Payment {
id: string; unit_id: string; unit_number: string; invoice_id: string; id: string; unit_id: string; unit_number: string; invoice_id: string;
@@ -20,6 +21,7 @@ interface Payment {
export function PaymentsPage() { export function PaymentsPage() {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: payments = [], isLoading } = useQuery<Payment[]>({ const { data: payments = [], isLoading } = useQuery<Payment[]>({
queryKey: ['payments'], queryKey: ['payments'],
@@ -74,7 +76,7 @@ export function PaymentsPage() {
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Title order={2}>Payments</Title> <Title order={2}>Payments</Title>
<Button leftSection={<IconPlus size={16} />} onClick={open}>Record Payment</Button> {!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={open}>Record Payment</Button>}
</Group> </Group>
<Table striped highlightOnHover> <Table striped highlightOnHover>
<Table.Thead> <Table.Thead>

View File

@@ -12,6 +12,7 @@ import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen }
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';
import { useIsReadOnly } from '../../stores/authStore';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types & constants // Types & constants
@@ -78,6 +79,7 @@ export function ProjectsPage() {
const [editing, setEditing] = useState<Project | null>(null); const [editing, setEditing] = useState<Project | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
// ---- Data fetching ---- // ---- Data fetching ----
@@ -331,14 +333,16 @@ export function ProjectsPage() {
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={projects.length === 0}> <Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={projects.length === 0}>
Export CSV Export CSV
</Button> </Button>
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()} {!isReadOnly && (<>
loading={importMutation.isPending}> <Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
Import CSV loading={importMutation.isPending}>
</Button> Import CSV
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} /> </Button>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}> <input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
+ Add Project <Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
</Button> + Add Project
</Button>
</>)}
</Group> </Group>
</Group> </Group>
@@ -451,9 +455,11 @@ export function ProjectsPage() {
</Table.Td> </Table.Td>
<Table.Td>{formatDate(p.planned_date)}</Table.Td> <Table.Td>{formatDate(p.planned_date)}</Table.Td>
<Table.Td> <Table.Td>
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}> {!isReadOnly && (
<IconEdit size={16} /> <ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
</ActionIcon> <IconEdit size={16} />
</ActionIcon>
)}
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}

View File

@@ -11,6 +11,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react'; import { IconPlus, IconEdit } 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 ReserveComponent { interface ReserveComponent {
id: string; name: string; category: string; description: string; id: string; name: string; category: string; description: string;
@@ -26,6 +27,7 @@ export function ReservesPage() {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<ReserveComponent | null>(null); const [editing, setEditing] = useState<ReserveComponent | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({ const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
queryKey: ['reserve-components'], queryKey: ['reserve-components'],
@@ -89,7 +91,7 @@ export function ReservesPage() {
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Title order={2}>Reserve Components</Title> <Title order={2}>Reserve Components</Title>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Component</Button> {!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Component</Button>}
</Group> </Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}> <SimpleGrid cols={{ base: 1, sm: 3 }}>
<Card withBorder p="md"> <Card withBorder p="md">
@@ -139,7 +141,7 @@ export function ReservesPage() {
{c.condition_rating}/10 {c.condition_rating}/10
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(c)}><IconEdit size={16} /></ActionIcon></Table.Td> <Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(c)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
</Table.Tr> </Table.Tr>
); );
})} })}

View File

@@ -12,6 +12,7 @@ import { IconPlus, IconEye, IconCheck, IconX, IconTrash, IconShieldCheck } from
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel'; import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
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 JournalEntryLine { interface JournalEntryLine {
id?: string; id?: string;
@@ -48,6 +49,7 @@ export function TransactionsPage() {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [viewId, setViewId] = useState<string | null>(null); const [viewId, setViewId] = useState<string | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({ const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
queryKey: ['journal-entries'], queryKey: ['journal-entries'],
@@ -164,9 +166,11 @@ export function TransactionsPage() {
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Title order={2}>Journal Entries</Title> <Title order={2}>Journal Entries</Title>
<Button leftSection={<IconPlus size={16} />} onClick={open}> {!isReadOnly && (
New Entry <Button leftSection={<IconPlus size={16} />} onClick={open}>
</Button> New Entry
</Button>
)}
</Group> </Group>
<Table striped highlightOnHover> <Table striped highlightOnHover>
@@ -216,14 +220,14 @@ export function TransactionsPage() {
<IconEye size={16} /> <IconEye size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
{!e.is_posted && !e.is_void && ( {!isReadOnly && !e.is_posted && !e.is_void && (
<Tooltip label="Post"> <Tooltip label="Post">
<ActionIcon variant="subtle" color="green" onClick={() => postMutation.mutate(e.id)}> <ActionIcon variant="subtle" color="green" onClick={() => postMutation.mutate(e.id)}>
<IconCheck size={16} /> <IconCheck size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
{e.is_posted && !e.is_void && ( {!isReadOnly && e.is_posted && !e.is_void && (
<Tooltip label="Void"> <Tooltip label="Void">
<ActionIcon variant="subtle" color="red" onClick={() => voidMutation.mutate(e.id)}> <ActionIcon variant="subtle" color="red" onClick={() => voidMutation.mutate(e.id)}>
<IconX size={16} /> <IconX size={16} />

View File

@@ -10,6 +10,7 @@ import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle, IconUpload,
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';
import { useIsReadOnly } from '../../stores/authStore';
interface Unit { interface Unit {
id: string; id: string;
@@ -42,6 +43,7 @@ export function UnitsPage() {
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
const { data: units = [], isLoading } = useQuery<Unit[]>({ const { data: units = [], isLoading } = useQuery<Unit[]>({
queryKey: ['units'], queryKey: ['units'],
@@ -163,18 +165,20 @@ export function UnitsPage() {
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={units.length === 0}> <Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={units.length === 0}>
Export CSV Export CSV
</Button> </Button>
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()} {!isReadOnly && (<>
loading={importMutation.isPending}> <Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
Import CSV loading={importMutation.isPending}>
</Button> Import CSV
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} /> </Button>
{hasGroups ? ( <input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Add Unit</Button> {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 label="Create an assessment group first">
</Tooltip> <Button leftSection={<IconPlus size={16} />} disabled>Add Unit</Button>
)} </Tooltip>
)}
</>)}
</Group> </Group>
</Group> </Group>
@@ -224,16 +228,18 @@ export function UnitsPage() {
</Table.Td> </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}> {!isReadOnly && (
<ActionIcon variant="subtle" onClick={() => handleEdit(u)}> <Group gap={4}>
<IconEdit size={16} /> <ActionIcon variant="subtle" onClick={() => handleEdit(u)}>
</ActionIcon> <IconEdit size={16} />
<Tooltip label="Delete unit">
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteConfirm(u)}>
<IconTrash size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> <Tooltip label="Delete unit">
</Group> <ActionIcon variant="subtle" color="red" onClick={() => setDeleteConfirm(u)}>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
)}
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}

View File

@@ -10,6 +10,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload } from '@tabler/icons-react'; import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload } 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';
import { parseCSV, downloadBlob } from '../../utils/csv'; import { parseCSV, downloadBlob } from '../../utils/csv';
interface Vendor { interface Vendor {
@@ -25,6 +26,7 @@ export function VendorsPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({ const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
queryKey: ['vendors'], queryKey: ['vendors'],
@@ -117,12 +119,14 @@ export function VendorsPage() {
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={vendors.length === 0}> <Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={vendors.length === 0}>
Export CSV Export CSV
</Button> </Button>
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()} {!isReadOnly && (<>
loading={importMutation.isPending}> <Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
Import CSV loading={importMutation.isPending}>
</Button> Import CSV
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} /> </Button>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Vendor</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>
</Group> </Group>
<TextInput placeholder="Search vendors..." leftSection={<IconSearch size={16} />} <TextInput placeholder="Search vendors..." leftSection={<IconSearch size={16} />}
@@ -146,7 +150,7 @@ export function VendorsPage() {
<Table.Td>{v.is_1099_eligible && <Badge color="orange" size="sm">1099</Badge>}</Table.Td> <Table.Td>{v.is_1099_eligible && <Badge color="orange" size="sm">1099</Badge>}</Table.Td>
<Table.Td>{v.last_negotiated ? new Date(v.last_negotiated).toLocaleDateString() : '-'}</Table.Td> <Table.Td>{v.last_negotiated ? new Date(v.last_negotiated).toLocaleDateString() : '-'}</Table.Td>
<Table.Td ta="right" ff="monospace">${parseFloat(v.ytd_payments || '0').toFixed(2)}</Table.Td> <Table.Td ta="right" ff="monospace">${parseFloat(v.ytd_payments || '0').toFixed(2)}</Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon></Table.Td> <Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>} {filtered.length === 0 && <Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>}

View File

@@ -42,6 +42,9 @@ interface AuthState {
logout: () => void; logout: () => void;
} }
/** Hook to check if the current user has read-only (viewer) access */
export const useIsReadOnly = () => useAuthStore((s) => s.currentOrg?.role === 'viewer');
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
persist( persist(
(set, get) => ({ (set, get) => ({