diff --git a/CLAUDE.md b/CLAUDE.md index 5066253..ea60504 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,8 +59,7 @@ npm run format:check # Check code formatting | `/ledger/chart-of-accounts` | getAccountTree, autoMapElements, mapping ops | shipped (auto-map UI live; CoA → US-GAAP mapping pending OLTP migration) | | `/ledger/transactions` | listTransactions, getTransaction, createJournalEntry (modal) | shipped (NewJournalEntryModal 2026-05-14) | | `/ledger/trial-balance` | getTrialBalance, getMappedTrialBalance | shipped | -| `/ledger/close` | getPeriodCloseStatus, closePeriod, reopenPeriod, listPeriodDrafts | shipped | -| `/ledger/schedules` | listInformationBlocks (filtered) | shipped | +| `/ledger/close` | getPeriodCloseStatus, closePeriod, reopenPeriod, listPeriodDrafts | shipped (also renders schedule/statement/rules blocks via BlockView) | | `/ledger/inbox` | listEventBlocks, getEventBlock, previewEventBlock, updateEventBlock | shipped 2026-05-01 | | `/agents` | listAgents, getAgent | shipped 2026-05-01 | | `/reports` `/reports/new` `/reports/[id]` | listReports, createReport, getReportPackage | shipped | diff --git a/src/app/(app)/ledger/schedules/content.tsx b/src/app/(app)/ledger/schedules/content.tsx deleted file mode 100644 index aca53d8..0000000 --- a/src/app/(app)/ledger/schedules/content.tsx +++ /dev/null @@ -1,595 +0,0 @@ -'use client' - -import { PageHeader } from '@/components/PageHeader' -import { - clients, - customTheme, - GraphFilters, - PageLayout, - useGraphContext, -} from '@/lib/core' -import type { - InformationBlock, - InformationBlockFact, - LedgerPeriodCloseStatus, -} from '@robosystems/client/clients' -import { - Badge, - Button, - Card, - Modal, - ModalBody, - ModalHeader, - Spinner, - Table, - TableBody, - TableCell, - TableHead, - TableHeadCell, - TableRow, -} from 'flowbite-react' -import type { FC } from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' -import { - HiCalendar, - HiCheck, - HiChevronRight, - HiClock, - HiExclamationCircle, -} from 'react-icons/hi' -import { TbFileInvoice } from 'react-icons/tb' - -/** Fact with element name resolved from the envelope's elements[] list. */ -type ScheduleFactRow = InformationBlockFact & { elementName: string } - -/** Pre-digested view of a schedule Information Block for the list UI. */ -type ScheduleRow = { - structureId: string - name: string - method: string | null - totalPeriods: number - periodsWithEntries: number -} - -/** Minimal shape FactsModal needs — pulled out so we're not dragging the - * full Information Block envelope through the component. */ -type ScheduleFactsTarget = { structureId: string; name: string } - -/** Shape of artifact.mechanics for schedule blocks (API contract v0.3.9+). */ -type ScheduleMechanics = { - kind?: string - scheduleMetadata?: { method?: string } - periodsWithEntries?: number -} - -/** Project an Information Block envelope onto the schedule list row shape. */ -function toScheduleRow(block: InformationBlock): ScheduleRow { - const mechanics = block.artifact.mechanics as ScheduleMechanics - const periodKeys = new Set( - block.facts.map((f) => `${f.periodStart ?? ''}_${f.periodEnd}`) - ) - return { - structureId: block.id, - name: block.name, - method: mechanics.scheduleMetadata?.method ?? null, - totalPeriods: periodKeys.size, - periodsWithEntries: mechanics.periodsWithEntries ?? 0, - } -} - -const STATUS_COLORS: Record = { - posted: 'success', - draft: 'warning', - pending: 'gray', - reversed: 'failure', -} - -const formatCurrencyDollars = (amount: number): string => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(amount) -} - -const formatDate = (dateString: string): string => { - const date = new Date(dateString + 'T00:00:00') - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - }) -} - -const formatMonth = (dateString: string): string => { - const date = new Date(dateString + 'T00:00:00') - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - }) -} - -// ── Period Close Panel ────────────────────────────────────────────────── - -interface PeriodClosePanelProps { - graphId: string - onEntryCreated: () => void -} - -const PeriodClosePanel: FC = ({ - graphId, - onEntryCreated, -}) => { - const [closeStatus, setCloseStatus] = - useState(null) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) - const [creatingEntry, setCreatingEntry] = useState(null) - - // Default to current month - const now = new Date() - const defaultStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01` - const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate() - const defaultEnd = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${lastDay}` - - const [periodStart, setPeriodStart] = useState(defaultStart) - const [periodEnd, setPeriodEnd] = useState(defaultEnd) - - const loadCloseStatus = useCallback(async () => { - try { - setIsLoading(true) - setError(null) - const status = await clients.ledger.getPeriodCloseStatus( - graphId, - periodStart, - periodEnd - ) - setCloseStatus(status) - } catch (err) { - console.error('Error loading close status:', err) - setError('Failed to load period close status.') - } finally { - setIsLoading(false) - } - }, [graphId, periodStart, periodEnd]) - - useEffect(() => { - if (graphId) { - loadCloseStatus() - } - }, [graphId, loadCloseStatus]) - - const handleCreateEntry = async (structureId: string) => { - try { - setCreatingEntry(structureId) - await clients.ledger.createClosingEntry( - graphId, - structureId, - periodEnd, - periodStart, - periodEnd - ) - onEntryCreated() - await loadCloseStatus() - } catch (err) { - console.error('Error creating closing entry:', err) - setError('Failed to create closing entry.') - } finally { - setCreatingEntry(null) - } - } - - if (isLoading) { - return ( -
- -
- ) - } - - if (error) { - return ( -
- - {error} -
- ) - } - - if (!closeStatus || closeStatus.schedules.length === 0) { - return ( -
- No schedules found for this period. -
- ) - } - - return ( -
-
-
- - setPeriodStart(e.target.value)} - className="rounded-lg border border-gray-300 bg-gray-50 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" - /> - to - setPeriodEnd(e.target.value)} - className="rounded-lg border border-gray-300 bg-gray-50 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" - /> -
- - {closeStatus.periodStatus} - -
- - - - Schedule - Amount - Status - - - - {closeStatus.schedules.map((item) => ( - - - {item.structureName} - - {formatCurrencyDollars(item.amount)} - - - {item.status} - - - - {item.status === 'pending' && ( - - )} - {item.status === 'draft' && ( - - - Draft created - - )} - {item.status === 'posted' && ( - - - Posted - - )} - - - ))} - -
- -
- Draft: {closeStatus.totalDraft} - Posted: {closeStatus.totalPosted} -
-
- ) -} - -// ── Schedule Facts Modal ──────────────────────────────────────────────── - -interface FactsModalProps { - graphId: string - schedule: ScheduleFactsTarget | null - onClose: () => void -} - -const FactsModal: FC = ({ graphId, schedule, onClose }) => { - const [facts, setFacts] = useState([]) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) - - useEffect(() => { - if (!schedule) return - - const loadFacts = async () => { - try { - setIsLoading(true) - setError(null) - const block = await clients.ledger.getInformationBlock( - graphId, - schedule.structureId - ) - if (!block) { - setFacts([]) - return - } - const elementsById = new Map(block.elements.map((e) => [e.id, e])) - setFacts( - block.facts.map((f) => ({ - ...f, - elementName: elementsById.get(f.elementId)?.name ?? '', - })) - ) - } catch (err) { - console.error('Error loading facts:', err) - setError('Failed to load schedule facts.') - } finally { - setIsLoading(false) - } - } - - loadFacts() - }, [graphId, schedule]) - - // Group facts by period - const groupedFacts = useMemo(() => { - const groups: Record = {} - for (const fact of facts) { - const key = `${fact.periodStart}_${fact.periodEnd}` - if (!groups[key]) groups[key] = [] - groups[key].push(fact) - } - return Object.entries(groups).sort(([a], [b]) => a.localeCompare(b)) - }, [facts]) - - return ( - - {schedule?.name} - Schedule Facts - - {isLoading ? ( -
- -
- ) : error ? ( -
- - {error} -
- ) : facts.length === 0 ? ( -

No facts found.

- ) : ( -
- - - Period - Element - Amount - - - {groupedFacts.map(([_key, periodFacts]) => - periodFacts.map((fact, idx) => ( - - {idx === 0 && ( - - {fact.periodStart - ? formatMonth(fact.periodStart) - : '—'} - - )} - {fact.elementName} - {formatCurrencyDollars(fact.value)} - - )) - )} - -
-
- )} -
-
- ) -} - -// ── Main Content ──────────────────────────────────────────────────────── - -const SchedulesContent: FC = function () { - const { state: graphState } = useGraphContext() - const [schedules, setSchedules] = useState([]) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - const [selectedSchedule, setSelectedSchedule] = - useState(null) - const [showClosePanel, setShowClosePanel] = useState(false) - - const currentGraph = useMemo(() => { - const roboledgerGraphs = graphState.graphs.filter(GraphFilters.roboledger) - return ( - roboledgerGraphs.find((g) => g.graphId === graphState.currentGraphId) ?? - roboledgerGraphs[0] - ) - }, [graphState.graphs, graphState.currentGraphId]) - - const loadSchedules = useCallback(async () => { - if (!currentGraph) { - setSchedules([]) - setIsLoading(false) - return - } - - try { - setIsLoading(true) - setError(null) - const blocks = await clients.ledger.listInformationBlocks( - currentGraph.graphId, - { blockType: 'schedule' } - ) - setSchedules(blocks.map(toScheduleRow)) - } catch (err) { - console.error('Error loading schedules:', err) - setError('Failed to load schedules.') - } finally { - setIsLoading(false) - } - }, [currentGraph]) - - useEffect(() => { - loadSchedules() - }, [loadSchedules]) - - return ( - - setShowClosePanel(!showClosePanel)} - > - - {showClosePanel ? 'Hide' : 'Period Close'} - - } - /> - - {error && ( - -
- - {error} -
-
- )} - - {showClosePanel && currentGraph && ( - -

- Period Close Status -

- -
- )} - - -
- {isLoading ? ( -
- -
- ) : schedules.length === 0 ? ( -
- -

- No Schedules Found -

-

- Schedules are created via the AI assistant or API. Ask your - assistant to set up a depreciation or amortization schedule. -

-
- ) : ( - - - Schedule Name - Periods - Entries Created - Progress - - - - {schedules.map((schedule) => { - const progress = - schedule.totalPeriods > 0 - ? Math.round( - (schedule.periodsWithEntries / - schedule.totalPeriods) * - 100 - ) - : 0 - - return ( - - -
- {schedule.name} - {schedule.method && ( - - {schedule.method.replaceAll('_', ' ')} - - )} -
-
- {schedule.totalPeriods} months - - {schedule.periodsWithEntries} / {schedule.totalPeriods} - - -
-
-
-
- - {progress}% - -
- - - - - - ) - })} - -
- )} -
-
- - {currentGraph && ( - setSelectedSchedule(null)} - /> - )} -
- ) -} - -export default SchedulesContent diff --git a/src/app/(app)/ledger/schedules/page.tsx b/src/app/(app)/ledger/schedules/page.tsx deleted file mode 100644 index 2a26ac2..0000000 --- a/src/app/(app)/ledger/schedules/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -'use client' - -import { useUser } from '@/lib/core' -import { Spinner } from '@/lib/core/ui-components' -import SchedulesContent from './content' - -export default function SchedulesPage() { - const { user, isLoading } = useUser() - - if (isLoading || !user) { - return - } - - return -} diff --git a/src/lib/core/auth-components/SignInForm.tsx b/src/lib/core/auth-components/SignInForm.tsx index 5c8ac04..b0c6866 100644 --- a/src/lib/core/auth-components/SignInForm.tsx +++ b/src/lib/core/auth-components/SignInForm.tsx @@ -17,6 +17,36 @@ export interface SignInFormProps { currentApp?: string } +/** + * Map a login failure to a user-facing message. A connectivity failure (the + * request never reached the server — `fetch` throws a `TypeError`) must NOT be + * reported as bad credentials: that sends users to reset a password that is + * actually correct. Reached-server auth rejection (401/403, or an empty/invalid + * auth body) stays "Invalid email or password"; 5xx gets its own message. + */ +export function loginErrorMessage(error: unknown): string { + const err = error as { + status?: number + response?: { status?: number } + message?: string + } + const status = err?.status ?? err?.response?.status + const message = String(err?.message ?? '') + + if ( + error instanceof TypeError || + /failed to fetch|networkerror|load failed|fetch failed|err_(connection|network|name_not_resolved)/i.test( + message + ) + ) { + return 'Unable to reach the server. Check your connection and try again.' + } + if (typeof status === 'number' && status >= 500) { + return 'The server ran into a problem. Please try again in a moment.' + } + return 'Invalid email or password' +} + export function SignInForm({ onSuccess, onRedirect, @@ -124,8 +154,8 @@ export function SignInForm({ // Use window.location.href for reliable redirect window.location.href = redirectTo - } catch (error: any) { - setError('Invalid email or password') + } catch (error: unknown) { + setError(loginErrorMessage(error)) setLoading(false) } } diff --git a/src/lib/core/auth-components/__tests__/SignInForm.test.tsx b/src/lib/core/auth-components/__tests__/SignInForm.test.tsx index f10a568..4d8a1cd 100644 --- a/src/lib/core/auth-components/__tests__/SignInForm.test.tsx +++ b/src/lib/core/auth-components/__tests__/SignInForm.test.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { RoboSystemsAuthClient } from '../../auth-core/client' import { useSSO } from '../../auth-core/sso' import type { AuthUser } from '../../auth-core/types' -import { SignInForm } from '../SignInForm' +import { SignInForm, loginErrorMessage } from '../SignInForm' vi.mock('next/image', () => ({ __esModule: true, @@ -460,6 +460,33 @@ describe('SignInForm', () => { }) }) + it('shows a connectivity message, not bad credentials, when the request never reaches the server', async () => { + mockAuthClient.login.mockRejectedValue(new TypeError('Failed to fetch')) + + render() + + await waitFor(() => { + expect(screen.queryByTestId('spinner')).not.toBeInTheDocument() + }) + + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'test@example.com' }, + }) + fireEvent.change(screen.getByLabelText(/password/i), { + target: { value: 'password123' }, + }) + fireEvent.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + expect( + screen.getByText(/unable to reach the server/i) + ).toBeInTheDocument() + }) + expect( + screen.queryByText(/invalid email or password/i) + ).not.toBeInTheDocument() + }) + it('should clear error on successful retry', async () => { mockAuthClient.login .mockRejectedValueOnce(new Error('First error')) @@ -502,3 +529,28 @@ describe('SignInForm', () => { }) }) }) + +describe('loginErrorMessage', () => { + it('treats a fetch TypeError as a connectivity failure', () => { + expect(loginErrorMessage(new TypeError('Failed to fetch'))).toMatch( + /reach the server/i + ) + }) + + it('treats a 5xx as a server problem', () => { + expect(loginErrorMessage({ status: 503 })).toMatch( + /server ran into a problem/i + ) + expect(loginErrorMessage({ response: { status: 500 } })).toMatch( + /server ran into a problem/i + ) + }) + + it('defaults a reached-server auth failure to invalid credentials', () => { + // 401 with empty body surfaces as the validate-throw (no status). + expect( + loginErrorMessage(new Error('Invalid SDK response: expected object')) + ).toBe('Invalid email or password') + expect(loginErrorMessage({ status: 401 })).toBe('Invalid email or password') + }) +})