diff --git a/llm-fe/src/llm-fe/components/DriveConnectionsSection/DriveConnectionsSection.tsx b/llm-fe/src/llm-fe/components/DriveConnectionsSection/DriveConnectionsSection.tsx index 9942a18..5e118cd 100644 --- a/llm-fe/src/llm-fe/components/DriveConnectionsSection/DriveConnectionsSection.tsx +++ b/llm-fe/src/llm-fe/components/DriveConnectionsSection/DriveConnectionsSection.tsx @@ -7,6 +7,7 @@ import { DriveProvider, connectDrive, disconnectDriveConnection, + driveSyncProgressPercent, fetchDriveConnections, formatDriveSyncError, parseResourceIdsInput, @@ -212,6 +213,44 @@ const SyncErrorText = styled.p` line-height: 1.4; `; +const ProgressWrap = styled.div` + margin-top: 0.85rem; +`; + +const ProgressTrack = styled.div` + width: 100%; + height: 8px; + border-radius: 999px; + overflow: hidden; + background: ${({ theme }) => + theme.darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.12)'}; +`; + +const ProgressFill = styled.div<{ $percent: number | null }>` + height: 100%; + width: ${({ $percent }) => ($percent == null ? '40%' : `${$percent}%`)}; + border-radius: 999px; + background: ${({ theme }) => theme.main}; + transition: width 0.25s ease; + ${({ $percent }) => + $percent == null + ? ` + animation: sync-indeterminate 1.2s ease-in-out infinite; + @keyframes sync-indeterminate { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(250%); } + } + ` + : ''} +`; + +const ProgressLabel = styled.p` + margin: 0.4rem 0 0 0; + font-size: 0.8rem; + color: ${({ theme }) => theme.colors.text}; + opacity: 0.7; +`; + const PROVIDER_LABELS: Record = { google: "Google Drive", microsoft: "OneDrive", @@ -297,7 +336,9 @@ const DriveConnectionsSection = ({ const settled = enqueued.connection.last_sync_status === 'pending' - ? await waitForDriveSyncSettlement(id) + ? await waitForDriveSyncSettlement(id, { + onProgress: (connection) => upsertConnection(connection), + }) : enqueued.connection; upsertConnection(settled); @@ -404,6 +445,9 @@ const DriveConnectionsSection = ({ ? conn.selected_resource_labels : conn.selected_resource_ids || []; const isSyncing = action === 'sync' || conn.last_sync_status === 'pending'; + const progressPercent = isSyncing ? driveSyncProgressPercent(conn) : null; + const processed = conn.sync_processed ?? 0; + const total = conn.sync_total ?? 0; return ( @@ -436,6 +480,19 @@ const DriveConnectionsSection = ({ + {isSyncing && ( + + + + + + {progressPercent == null + ? 'Discovering files…' + : `${processed} / ${total} files (${progressPercent}%)`} + + + )} + {conn.last_sync_status === 'error' && conn.last_sync_error && ( {formatDriveSyncError(conn.last_sync_error)} )} diff --git a/llm-fe/src/llm-fe/components/PromptHeatmapCard/PromptHeatmapCard.tsx b/llm-fe/src/llm-fe/components/PromptHeatmapCard/PromptHeatmapCard.tsx new file mode 100644 index 0000000..36a5a20 --- /dev/null +++ b/llm-fe/src/llm-fe/components/PromptHeatmapCard/PromptHeatmapCard.tsx @@ -0,0 +1,230 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import styled, { useTheme } from 'styled-components'; +import { AxiosResponse } from 'axios'; +import { axiosInstance } from '../../../axiosApi'; + +export type PromptHeatmapData = { + tz: string; + total: number; + max: number; + days: string[]; + hours: number[]; + matrix: number[][]; + most_active_day: string | null; + most_active_hour: number | null; + peak_cell: { day: string; hour: number; count: number } | null; +}; + +const GlassCard = styled.div` + background: ${({ theme }) => theme.colors.cardBackground}; + backdrop-filter: blur(10px); + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 1rem; + padding: 2rem; + width: 100%; + max-width: 1000px; + margin-bottom: 2rem; + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); +`; + +const CardTitle = styled.h2` + font-size: 1.8rem; + margin-bottom: 0.5rem; + color: ${({ theme }) => theme.colors.text}; + border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder}; + padding-bottom: 1rem; +`; + +const Subtitle = styled.p` + margin: 0.75rem 0 1.25rem 0; + color: ${({ theme }) => theme.colors.text}; + opacity: 0.7; + font-size: 0.95rem; + line-height: 1.5; +`; + +const GridScroll = styled.div` + overflow-x: auto; +`; + +const HeatmapGrid = styled.div` + display: grid; + grid-template-columns: 3rem repeat(24, minmax(14px, 1fr)); + gap: 3px; + min-width: 520px; +`; + +const Corner = styled.div``; + +const AxisLabel = styled.div` + font-size: 0.7rem; + color: ${({ theme }) => theme.colors.text}; + opacity: 0.55; + display: flex; + align-items: center; + justify-content: center; +`; + +const DayLabel = styled(AxisLabel)` + justify-content: flex-start; + padding-left: 0.15rem; +`; + +const Cell = styled.button<{ $intensity: number; $color: string }>` + aspect-ratio: 1; + border: none; + border-radius: 3px; + padding: 0; + cursor: default; + background: ${({ $intensity, $color, theme }) => + $intensity <= 0 + ? theme.darkMode + ? 'rgba(255,255,255,0.08)' + : 'rgba(0,0,0,0.08)' + : $color}; + opacity: ${({ $intensity }) => ($intensity <= 0 ? 1 : 0.35 + $intensity * 0.65)}; + + &:hover { + outline: 1px solid ${({ theme }) => theme.colors.text}66; + } +`; + +const Footer = styled.p` + margin: 1rem 0 0 0; + color: ${({ theme }) => theme.colors.text}; + opacity: 0.7; + font-size: 0.9rem; + line-height: 1.5; +`; + +const StatusText = styled.p` + color: ${({ theme }) => theme.colors.text}; + opacity: 0.7; +`; + +const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => { + const cleaned = hex.replace('#', ''); + if (cleaned.length !== 6) return null; + return { + r: parseInt(cleaned.slice(0, 2), 16), + g: parseInt(cleaned.slice(2, 4), 16), + b: parseInt(cleaned.slice(4, 6), 16), + }; +}; + +const PromptHeatmapCard = (): JSX.Element => { + const theme = useTheme(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [hover, setHover] = useState<{ day: string; hour: number; count: number } | null>(null); + + useEffect(() => { + let cancelled = false; + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + + (async () => { + try { + const response: AxiosResponse = await axiosInstance.get( + '/analytics/user_prompt_heatmap/', + { params: { tz } } + ); + if (!cancelled) { + setData(response.data); + setError(false); + } + } catch { + if (!cancelled) { + setError(true); + setData(null); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + const themeColor = theme?.main || '#4a90e2'; + const rgb = useMemo(() => hexToRgb(themeColor), [themeColor]); + + const cellColor = (count: number, max: number) => { + if (!rgb || max <= 0 || count <= 0) return themeColor; + const t = count / max; + return `rgb(${Math.round(rgb.r * (0.4 + 0.6 * t))}, ${Math.round( + rgb.g * (0.4 + 0.6 * t) + )}, ${Math.round(rgb.b * (0.4 + 0.6 * t))})`; + }; + + const hourLabels = [0, 6, 12, 18, 23]; + + return ( + + Prompt activity + + When you send prompts across the week (local timezone + {data?.tz ? `: ${data.tz}` : ''}). + + + {loading && Loading heatmap…} + {error && Could not load prompt activity.} + {!loading && !error && data && ( + <> + + + + {data.hours.map((hour) => ( + {hourLabels.includes(hour) ? hour : ''} + ))} + {data.days.map((day, dayIdx) => ( + + {day} + {data.hours.map((hour) => { + const count = data.matrix[dayIdx]?.[hour] ?? 0; + const intensity = data.max > 0 ? count / data.max : 0; + return ( + setHover({ day, hour, count })} + onMouseLeave={() => setHover(null)} + onFocus={() => setHover({ day, hour, count })} + onBlur={() => setHover(null)} + /> + ); + })} + + ))} + + +
+ {hover + ? `${hover.day} ${hover.hour}:00 — ${hover.count} prompt${hover.count === 1 ? '' : 's'}` + : data.total === 0 + ? 'No prompts yet. Chat a bit and this grid fills in.' + : [ + `${data.total} prompt${data.total === 1 ? '' : 's'} total`, + data.most_active_day ? `Most active day: ${data.most_active_day}` : null, + data.most_active_hour != null + ? `Most active hour: ${data.most_active_hour}:00` + : null, + data.peak_cell + ? `Peak: ${data.peak_cell.day} ${data.peak_cell.hour}:00 (${data.peak_cell.count})` + : null, + ] + .filter(Boolean) + .join(' · ')} +
+ + )} +
+ ); +}; + +export default PromptHeatmapCard; diff --git a/llm-fe/src/llm-fe/pages/Analytics/Analytics.test.tsx b/llm-fe/src/llm-fe/pages/Analytics/Analytics.test.tsx new file mode 100644 index 0000000..fbab829 --- /dev/null +++ b/llm-fe/src/llm-fe/pages/Analytics/Analytics.test.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ThemeProvider } from 'styled-components'; +import AnalyticsPage from './Analytics'; +import { AccountContext } from '../../contexts/AccountContext'; +import { AuthContext } from '../../contexts/AuthContext'; +import { Account } from '../../data'; + +const mockGet = jest.fn(); + +jest.mock('../../../axiosApi', () => ({ + axiosInstance: { + get: (...args: unknown[]) => mockGet(...args), + defaults: { headers: { common: {} as Record } }, + }, + applyAccessToken: jest.fn(), +})); + +jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null); +jest.mock('../../components/Header2/Header2', () => () => null); + +beforeAll(() => { + class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} + } + (global as unknown as { ResizeObserver: typeof ResizeObserverMock }).ResizeObserver = + ResizeObserverMock; +}); + +const theme = { + main: '#4a90e2', + focus: '#224466', + darkMode: true, + colors: { + text: '#ffffff', + cardBackground: 'rgba(0,0,0,0.3)', + cardBorder: 'rgba(255,255,255,0.1)', + }, +}; + +const emptyHeatmap = { + tz: 'UTC', + total: 0, + max: 0, + days: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], + hours: Array.from({ length: 24 }, (_, i) => i), + matrix: Array.from({ length: 7 }, () => Array(24).fill(0)), + most_active_day: null, + most_active_hour: null, + peak_cell: null, +}; + +const renderPage = (accountInit?: ConstructorParameters[0]) => { + const account = new Account(accountInit || { email: 'user@example.com' }); + return render( + + + + + + + + + + ); +}; + +describe('AnalyticsPage (#94)', () => { + beforeEach(() => { + mockGet.mockReset(); + mockGet.mockImplementation((url: string) => { + if (url.includes('user_prompt_heatmap')) { + return Promise.resolve({ data: emptyHeatmap }); + } + return Promise.resolve({ data: [] }); + }); + }); + + it('shows your activity section with heatmap for all users', async () => { + renderPage(); + + expect(await screen.findByText('Your activity')).toBeInTheDocument(); + expect(await screen.findByText('Prompt activity')).toBeInTheDocument(); + expect(screen.queryByText('Company')).not.toBeInTheDocument(); + }); + + it('shows company section for company managers', async () => { + renderPage({ + email: 'mgr@example.com', + is_company_manager: true, + company: { id: 1, name: 'Acme', state: '', zipcode: '', address: '' }, + }); + + expect(await screen.findByText('Company')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('Team seat activity')).toBeInTheDocument(); + }); + }); +}); diff --git a/llm-fe/src/llm-fe/pages/Analytics/Analytics.tsx b/llm-fe/src/llm-fe/pages/Analytics/Analytics.tsx index 1779a12..1ab3464 100644 --- a/llm-fe/src/llm-fe/pages/Analytics/Analytics.tsx +++ b/llm-fe/src/llm-fe/pages/Analytics/Analytics.tsx @@ -6,9 +6,9 @@ import { axiosInstance } from "../../../axiosApi" import { AxiosResponse } from "axios" import { AdminAnalytics, AdminAnalyticsType, CompanyUsageAnalytics, CompanyUsageAnalyticsType, UserConversationAnalytics, UserConvesationAnalyticsType, UserPromptAnalytics, UserPromptAnalyticsType } from "../../data" import ParticleBackground from "../../components/ParticleBackground/ParticleBackground" +import PromptHeatmapCard from "../../components/PromptHeatmapCard/PromptHeatmapCard" import styled, { ThemeContext } from "styled-components" -// Styled Components const PageContainer = styled.div` position: relative; width: 100vw; @@ -18,7 +18,6 @@ const PageContainer = styled.div` flex-direction: column; color: ${({ theme }) => theme.colors.text}; font-family: 'Inter', sans-serif; - /* background-color: ${({ theme }) => theme.colors.background}; Removed to show particles */ `; const ContentWrapper = styled.div` @@ -42,6 +41,29 @@ const ContentWrapper = styled.div` } `; +const Section = styled.section` + width: 100%; + max-width: 1200px; + margin-bottom: 1rem; +`; + +const SectionHeading = styled.h1` + width: 100%; + font-size: 1.35rem; + font-weight: 700; + color: ${({ theme }) => theme.colors.text}; + margin: 0 0 1rem 0; + letter-spacing: 0.02em; +`; + +const SectionHint = styled.p` + margin: -0.5rem 0 1.25rem 0; + color: ${({ theme }) => theme.colors.text}; + opacity: 0.65; + font-size: 0.95rem; + line-height: 1.5; +`; + const GlassCard = styled.div` background: ${({ theme }) => theme.colors.cardBackground}; backdrop-filter: blur(10px); @@ -96,11 +118,11 @@ const UserPromptAnalyticsCard = (): JSX.Element => { }, []) return ( - Prompt Usage + Prompt volume - + @@ -132,11 +154,11 @@ const UserConversationAnalyticsCard = (): JSX.Element => { return ( - Conversation Usage + Conversation volume - + @@ -168,7 +190,7 @@ const CompanyUsageAnalyticsCard = (): JSX.Element => { return ( - Account Usage + Team seat activity @@ -202,7 +224,7 @@ const AdminAnalyticsCard = (): JSX.Element => { }, []) return ( - Response Times + Response times (ops) @@ -227,20 +249,57 @@ const AdminAnalyticsCard = (): JSX.Element => { ) } +const isOpsAdmin = (email?: string, role?: string): boolean => { + if (role && ['admin', 'ops', 'staff'].includes(role.toLowerCase())) { + return true; + } + return email === "ryan+admin@aimloperations.com"; +}; const AnalyticsInner = (): JSX.Element => { const { account } = useContext(AccountContext) + const showCompany = Boolean(account?.is_company_manager || account?.company); + const showAdmin = isOpsAdmin(account?.email, account?.role); return ( <> - - - - +
+ Your activity + + Personal prompt timing and volume. Other users' private messages are never shown here. + + + + + + +
- {account?.is_company_manager ? : <>} - {account?.email === "ryan+admin@aimloperations.com" ? : <>} + {showCompany && ( +
+ Company + + Aggregated seat and usage trends for your workspace. No message content. + + {account?.is_company_manager ? ( + + ) : ( + + Company insights +

+ Detailed team seat charts are available to company managers. +

+
+ )} +
+ )} + {showAdmin && ( +
+ Operations + +
+ )} ) } diff --git a/llm-fe/src/llm-fe/pages/DocumentStoragePage/DocumentStoragePage.test.tsx b/llm-fe/src/llm-fe/pages/DocumentStoragePage/DocumentStoragePage.test.tsx index 98b3b70..13609f4 100644 --- a/llm-fe/src/llm-fe/pages/DocumentStoragePage/DocumentStoragePage.test.tsx +++ b/llm-fe/src/llm-fe/pages/DocumentStoragePage/DocumentStoragePage.test.tsx @@ -24,6 +24,9 @@ jest.mock('../../../axiosApi', () => ({ applyAccessToken: jest.fn(), })); +jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null); +jest.mock('../../components/Header2/Header2', () => () => null); + const theme = { main: '#4a90e2', focus: '#224466', @@ -60,8 +63,10 @@ const mockGetByUrl = (overrides: { rag: boolean }) => { if (url === '/finance/subscription/') { return Promise.resolve(subscriptionResponse(overrides.rag)); } - if (url === '/documents/') { - return Promise.resolve({ data: [] }); + if (typeof url === 'string' && url.startsWith('/documents/')) { + return Promise.resolve({ + data: { count: 0, page: 1, page_size: 20, scope: 'personal', results: [] }, + }); } if (url === '/drive/connections/') { return Promise.resolve({ data: [] }); @@ -70,8 +75,14 @@ const mockGetByUrl = (overrides: { rag: boolean }) => { }); }; -const renderPage = (isCompanyManager = false) => { - const account = new Account({ email: 'user@example.com', is_company_manager: isCompanyManager }); +const renderPage = (options?: { isCompanyManager?: boolean; hasCompany?: boolean }) => { + const isCompanyManager = options?.isCompanyManager ?? false; + const hasCompany = options?.hasCompany ?? false; + const account = new Account({ + email: 'user@example.com', + is_company_manager: isCompanyManager, + company: hasCompany ? { id: 1, name: 'Acme', state: '', zipcode: '', address: '' } : undefined, + }); return render( @@ -93,7 +104,7 @@ const renderPage = (isCompanyManager = false) => { ); }; -describe('DocumentStoragePage (#81/#82/#83/#84)', () => { +describe('DocumentStoragePage (#81/#82/#83/#84/#93)', () => { beforeEach(() => { mockGet.mockReset(); mockPost.mockReset(); @@ -113,27 +124,62 @@ describe('DocumentStoragePage (#81/#82/#83/#84)', () => { '/account/' ); expect(screen.queryByText('Company documents')).not.toBeInTheDocument(); + expect(screen.queryByText('Personal documents')).not.toBeInTheDocument(); expect(screen.queryByText('Upload a Document')).not.toBeInTheDocument(); }); - it('shows the company documents table, upload card, and personal Cloud drives section when RAG is allowed', async () => { + it('shows personal documents for users without a company', async () => { mockGetByUrl({ rag: true }); - renderPage(false); + renderPage({ hasCompany: false }); - expect(await screen.findByText('Company documents')).toBeInTheDocument(); + expect(await screen.findByText('Personal documents')).toBeInTheDocument(); + expect(screen.queryByText('Company documents')).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: 'Company' })).not.toBeInTheDocument(); + expect( + screen.queryByText(/shared with your whole company workspace/i) + ).not.toBeInTheDocument(); expect(screen.getByText('Upload a Document')).toBeInTheDocument(); expect(screen.getByText('Cloud drives')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /connect google drive/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /connect onedrive/i })).toBeInTheDocument(); expect(screen.queryByText('Company knowledge sources')).not.toBeInTheDocument(); }); + it('shows company/personal tabs and company helper copy for company members', async () => { + mockGetByUrl({ rag: true }); + + renderPage({ hasCompany: true }); + + expect(await screen.findByText('Company documents')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Personal' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Company' })).toBeInTheDocument(); + expect(screen.getByText(/shared with your whole company workspace/i)).toBeInTheDocument(); + }); + it('also shows the company knowledge sources section for company managers (#84)', async () => { mockGetByUrl({ rag: true }); - renderPage(true); + renderPage({ isCompanyManager: true, hasCompany: true }); expect(await screen.findByText('Company knowledge sources')).toBeInTheDocument(); }); + + it('requests documents with workspace/page/search params (#93)', async () => { + mockGetByUrl({ rag: true }); + + renderPage({ hasCompany: true }); + + await screen.findByText('Company documents'); + await waitFor(() => { + expect(mockGet).toHaveBeenCalledWith( + '/documents/', + expect.objectContaining({ + params: expect.objectContaining({ + workspace: 'company', + page: 1, + page_size: 20, + }), + }) + ); + }); + }); }); diff --git a/llm-fe/src/llm-fe/pages/DocumentStoragePage/DocumentStoragePage.tsx b/llm-fe/src/llm-fe/pages/DocumentStoragePage/DocumentStoragePage.tsx index 2d01060..f8e0659 100644 --- a/llm-fe/src/llm-fe/pages/DocumentStoragePage/DocumentStoragePage.tsx +++ b/llm-fe/src/llm-fe/pages/DocumentStoragePage/DocumentStoragePage.tsx @@ -20,7 +20,6 @@ const PageContainer = styled.div` flex-direction: column; color: ${({ theme }) => theme.colors.text}; font-family: 'Inter', sans-serif; - /* background-color: ${({ theme }) => theme.colors.background}; Removed to show particles */ `; const ContentWrapper = styled.div` @@ -64,6 +63,72 @@ const CardTitle = styled.h2` padding-bottom: 1rem; `; +const TabRow = styled.div` + display: flex; + gap: 0.5rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; +`; + +const TabButton = styled.button<{ $active: boolean }>` + background: ${({ $active, theme }) => ($active ? theme.main : 'transparent')}; + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 0.5rem; + color: ${({ $active, theme }) => ($active ? '#fff' : theme.colors.text)}; + padding: 0.55rem 1rem; + font-weight: 600; + cursor: pointer; +`; + +const Toolbar = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-bottom: 1rem; +`; + +const SearchInput = styled.input` + flex: 1; + min-width: 180px; + background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'}; + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 0.5rem; + padding: 0.6rem 0.8rem; + color: ${({ theme }) => theme.colors.text}; + + &::placeholder { + color: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.4)'}; + } +`; + +const PaginationRow = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + margin-top: 1rem; + color: ${({ theme }) => theme.colors.text}; + opacity: 0.8; + font-size: 0.9rem; +`; + +const PageButton = styled.button` + background: transparent; + border: 1px solid ${({ theme }) => theme.colors.cardBorder}; + border-radius: 0.5rem; + color: ${({ theme }) => theme.colors.text}; + padding: 0.4rem 0.85rem; + font-weight: 600; + cursor: pointer; + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +`; + const StyledTable = styled.table` width: 100%; border-collapse: collapse; @@ -79,6 +144,15 @@ const Th = styled.th` font-weight: 600; `; +const SortableTh = styled(Th)` + cursor: pointer; + user-select: none; + + &:hover { + opacity: 1; + } +`; + const Td = styled.td` padding: 1rem; border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder}; @@ -251,65 +325,194 @@ const DismissButton = styled.button` } `; +const EmptyRow = styled.td` + padding: 1.5rem 1rem; + color: ${({ theme }) => theme.colors.text}; + opacity: 0.65; + text-align: center; +`; + +export type DocumentWorkspaceScope = 'personal' | 'company'; + +type DocumentsListResponse = { + count: number; + page: number; + page_size: number; + scope: DocumentWorkspaceScope; + results: DocumentType[]; +}; + +type SortField = 'name' | 'created' | 'processed' | 'active'; + type DocumentTableCardProps = { + scope: DocumentWorkspaceScope; + showTabs: boolean; + onScopeChange: (scope: DocumentWorkspaceScope) => void; documents: Document[]; + total: number; + page: number; + pageSize: number; + search: string; + ordering: string; + loading: boolean; + onSearchChange: (value: string) => void; + onSort: (field: SortField) => void; + onPageChange: (page: number) => void; onToggleActive: (id: number, active: boolean) => void; -} +}; + +const sortLabel = (field: SortField, ordering: string): string => { + const labels: Record = { + name: 'Name', + created: 'Date Uploaded', + processed: 'Processed', + active: 'Active', + }; + if (ordering === field) return `${labels[field]} ↑`; + if (ordering === `-${field}`) return `${labels[field]} ↓`; + return labels[field]; +}; + +const DocumentStorageTableCard = ({ + scope, + showTabs, + onScopeChange, + documents, + total, + page, + pageSize, + search, + ordering, + loading, + onSearchChange, + onSort, + onPageChange, + onToggleActive, +}: DocumentTableCardProps): JSX.Element => { + const title = scope === 'company' ? 'Company documents' : 'Personal documents'; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); -const CompanyDocumentStorageTableCard = ({ documents, onToggleActive }: DocumentTableCardProps): JSX.Element => { return ( - Company documents + {title} + + {showTabs && ( + + onScopeChange('personal')} + > + Personal + + onScopeChange('company')} + > + Company + + + )} + + + onSearchChange(event.target.value)} + aria-label="Search documents" + /> + +
- Name - Date Uploaded - Processed - Active + onSort('name')}>{sortLabel('name', ordering)} + onSort('created')}>{sortLabel('created', ordering)} + onSort('processed')}>{sortLabel('processed', ordering)} + onSort('active')}>{sortLabel('active', ordering)} - {documents.map((doc) => ( - - {doc.name} - {doc.date_uploaded} - - {doc.processed ? ( - - ) : ( - - )} - - - - onToggleActive(doc.id, event.target.checked)} - /> - - - + {loading ? ( + + Loading documents… - ))} + ) : documents.length === 0 ? ( + + No documents found. + + ) : ( + documents.map((doc) => ( + + {doc.name} + {doc.date_uploaded} + + {doc.processed ? ( + + ) : ( + + )} + + + + onToggleActive(doc.id, event.target.checked)} + /> + + + + + )) + )}
- - These documents are shared with your whole company workspace. Want to search your own - files privately? Connect a personal cloud drive in the Cloud drives section below. - + + + + {total === 0 + ? '0 documents' + : `Showing ${(page - 1) * pageSize + 1}–${Math.min(page * pageSize, total)} of ${total}`} + +
+ onPageChange(page - 1)}> + Previous + + = totalPages} + onClick={() => onPageChange(page + 1)} + > + Next + +
+
+ + {scope === 'company' && ( + + These documents are shared with your whole company workspace. Want to search your own + files privately? Connect a personal cloud drive in the Cloud drives section below. + + )}
- ) -} + ); +}; type DocumentUploadCardProps = { + scope: DocumentWorkspaceScope; onUploaded: () => void | Promise; -} +}; -const DocumentUploadCard = ({ onUploaded }: DocumentUploadCardProps): JSX.Element => { +const DocumentUploadCard = ({ scope, onUploaded }: DocumentUploadCardProps): JSX.Element => { const [selectedFile, setSelectedFile] = useState(null); const [uploading, setUploading] = useState(false); @@ -319,23 +522,23 @@ const DocumentUploadCard = ({ onUploaded }: DocumentUploadCardProps): JSX.Elemen } setUploading(true); try { - await axiosInstance.post('/documents/', { - file: selectedFile - }, + await axiosInstance.post( + '/documents/', + { file: selectedFile }, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }) + headers: { 'Content-Type': 'multipart/form-data' }, + params: { workspace: scope }, + } + ); setSelectedFile(null); await onUploaded(); } catch (error) { - console.log(error) + console.log(error); } finally { setUploading(false); } - } + }; const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files.length > 0) { @@ -361,35 +564,83 @@ const DocumentUploadCard = ({ onUploaded }: DocumentUploadCardProps): JSX.Elemen )}
- ) -} + ); +}; + +const PAGE_SIZE = 20; const DocumentStoragePageInner = (): JSX.Element => { const [documents, setDocuments] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [searchInput, setSearchInput] = useState(''); + const [search, setSearch] = useState(''); + const [ordering, setOrdering] = useState('-created'); + const [loading, setLoading] = useState(true); const { account } = useContext(AccountContext); const [searchParams, setSearchParams] = useSearchParams(); const [showDriveConnectedBanner, setShowDriveConnectedBanner] = useState(false); - const fetchDocuments = useCallback(async () => { - try { - const { data, }: AxiosResponse = await axiosInstance.get(`/documents/`); - setDocuments(data.map((item) => new Document({ + const hasCompany = Boolean(account?.company); + const [scope, setScope] = useState( + hasCompany ? 'company' : 'personal' + ); - id: item.id, - name: item.file.replace(/^.*[\\/]/, ''), - date_uploaded: item.created.substring(0, 10), - active: item.active, - processed: item.processed, - - }))) - } catch (error) { - console.log(error) + useEffect(() => { + if (!hasCompany && scope === 'company') { + setScope('personal'); } - }, []) + }, [hasCompany, scope]); + + useEffect(() => { + const handle = window.setTimeout(() => { + setSearch(searchInput.trim()); + setPage(1); + }, 300); + return () => window.clearTimeout(handle); + }, [searchInput]); + + const fetchDocuments = useCallback(async () => { + setLoading(true); + try { + const { data }: AxiosResponse = await axiosInstance.get( + `/documents/`, + { + params: { + workspace: scope, + page, + page_size: PAGE_SIZE, + search: search || undefined, + ordering, + }, + } + ); + const results = Array.isArray(data?.results) ? data.results : []; + setTotal(typeof data?.count === 'number' ? data.count : results.length); + setDocuments( + results.map( + (item) => + new Document({ + id: item.id, + name: item.file.replace(/^.*[\\/]/, ''), + date_uploaded: item.created.substring(0, 10), + active: item.active, + processed: item.processed, + }) + ) + ); + } catch (error) { + console.log(error); + setDocuments([]); + setTotal(0); + } finally { + setLoading(false); + } + }, [scope, page, search, ordering]); useEffect(() => { fetchDocuments(); - }, [fetchDocuments]) + }, [fetchDocuments]); useEffect(() => { if (searchParams.get('drive_connected') === '1') { @@ -411,6 +662,20 @@ const DocumentStoragePageInner = (): JSX.Element => { } }, []); + const handleSort = (field: SortField) => { + setPage(1); + setOrdering((prev) => { + if (prev === field) return `-${field}`; + if (prev === `-${field}`) return field; + return field === 'created' ? '-created' : field; + }); + }; + + const handleScopeChange = (next: DocumentWorkspaceScope) => { + setScope(next); + setPage(1); + }; + return ( <> {showDriveConnectedBanner && ( @@ -426,8 +691,23 @@ const DocumentStoragePageInner = (): JSX.Element => { )} - - + + { /> )} - ) -} + ); +}; const DocumentStoragePage = (): JSX.Element => { const { hasRag, loading } = useSubscription(); @@ -477,7 +757,7 @@ const DocumentStoragePage = (): JSX.Element => { )} - ) -} + ); +}; export default DocumentStoragePage; diff --git a/llm-fe/src/llm-fe/utils/drive.test.ts b/llm-fe/src/llm-fe/utils/drive.test.ts index f463b37..85fd5ab 100644 --- a/llm-fe/src/llm-fe/utils/drive.test.ts +++ b/llm-fe/src/llm-fe/utils/drive.test.ts @@ -1,9 +1,27 @@ import { driveConnectUrl, + driveSyncProgressPercent, formatDriveSyncError, parseResourceIdsInput, } from './drive'; +describe('driveSyncProgressPercent', () => { + it('returns null when total is unknown', () => { + expect(driveSyncProgressPercent({ id: 1, provider: 'google', sync_total: 0 })).toBeNull(); + }); + + it('computes rounded percent from processed/total', () => { + expect( + driveSyncProgressPercent({ + id: 1, + provider: 'google', + sync_total: 4, + sync_processed: 1, + }) + ).toBe(25); + }); +}); + describe('parseResourceIdsInput', () => { it('splits comma and newline separated ids and trims whitespace', () => { expect(parseResourceIdsInput('abc, def\nghi ,, ')).toEqual(['abc', 'def', 'ghi']); diff --git a/llm-fe/src/llm-fe/utils/drive.ts b/llm-fe/src/llm-fe/utils/drive.ts index d6b2da4..49358c5 100644 --- a/llm-fe/src/llm-fe/utils/drive.ts +++ b/llm-fe/src/llm-fe/utils/drive.ts @@ -21,6 +21,11 @@ export type DriveConnectionType = { last_sync_at?: string | null; last_sync_status?: DriveSyncStatus | string | null; last_sync_error?: string; + sync_total?: number; + sync_processed?: number; + sync_added?: number; + sync_updated?: number; + sync_failed?: number; created?: string; }; @@ -73,7 +78,11 @@ const DEFAULT_SYNC_TIMEOUT_MS = 120_000; /** Poll connections until the target leaves ``pending`` (or timeout). */ export async function waitForDriveSyncSettlement( connectionId: number, - options?: { intervalMs?: number; timeoutMs?: number } + options?: { + intervalMs?: number; + timeoutMs?: number; + onProgress?: (connection: DriveConnectionType) => void; + } ): Promise { const intervalMs = options?.intervalMs ?? DEFAULT_SYNC_POLL_MS; const timeoutMs = options?.timeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS; @@ -85,6 +94,7 @@ export async function waitForDriveSyncSettlement( if (!connection) { throw new Error('Drive connection disappeared while syncing.'); } + options?.onProgress?.(connection); if (connection.last_sync_status !== 'pending') { return connection; } @@ -93,6 +103,16 @@ export async function waitForDriveSyncSettlement( throw new Error('Drive sync timed out. Check status and try again.'); } +/** Percent complete when total is known; otherwise null (indeterminate). */ +export function driveSyncProgressPercent(connection: DriveConnectionType): number | null { + const total = connection.sync_total ?? 0; + if (total <= 0) { + return null; + } + const processed = Math.min(connection.sync_processed ?? 0, total); + return Math.round((processed / total) * 100); +} + /** Shorten long provider JSON error blobs for toasts. */ export function formatDriveSyncError(raw?: string | null, maxLen = 280): string { const text = (raw || '').trim();