Build the agent-native compute CRM platform
CI / verify (push) Successful in 3m6s

This commit is contained in:
2026-08-13 01:39:01 -07:00
parent bfd2f8d95a
commit 853bde2265
160 changed files with 61812 additions and 483 deletions
@@ -0,0 +1,195 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronLeft, ChevronRight, ExternalLink, FileSpreadsheet, LoaderCircle, Search, Unplug } from 'lucide-react';
import { ApiError, api, get, post, relativeTime } from '@/lib/api';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Input, Skeleton } from '@/components/ui';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export interface GoogleParsedTable {
fileName: string;
sheetName: string | null;
headers: string[];
rows: string[][];
warnings: string[];
}
interface ConnectionStatus {
configured: boolean;
connected: boolean;
connectedAt: string | null;
scopes: string[];
}
interface DriveFile {
id: string;
name: string;
modifiedTime: string | null;
}
interface FilePage {
files: DriveFile[];
nextPageToken: string | null;
incomplete: boolean;
}
interface SpreadsheetMetadata {
title: string;
sheets: { sheetId: number; title: string; rowCount: number; columnCount: number }[];
}
export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedTable): void }) {
const queryClient = useQueryClient();
const [searchDraft, setSearchDraft] = useState('');
const [search, setSearch] = useState('');
const [pageToken, setPageToken] = useState<string | null>(null);
const [previousTokens, setPreviousTokens] = useState<(string | null)[]>([]);
const [spreadsheetId, setSpreadsheetId] = useState('');
const [sheetId, setSheetId] = useState('');
const [range, setRange] = useState('A1:Z2001');
const { data: status, isLoading: statusLoading } = useQuery({
queryKey: ['google-sheets', 'status'],
queryFn: () => get<ConnectionStatus>('/api/imports/google/status'),
});
const connect = useMutation({
mutationFn: () => post<{ authorizationUrl: string }>('/api/imports/google/connect', {}),
onSuccess: ({ authorizationUrl }) => window.location.assign(authorizationUrl),
});
const disconnect = useMutation({
mutationFn: () => api<void>('/api/imports/google/connection', { method: 'DELETE' }),
onSuccess: async () => {
setSpreadsheetId('');
setSheetId('');
await queryClient.invalidateQueries({ queryKey: ['google-sheets'] });
},
});
const files = useQuery({
queryKey: ['google-sheets', 'files', search, pageToken],
queryFn: () => {
const parameters = new URLSearchParams();
if (search) parameters.set('search', search);
if (pageToken) parameters.set('pageToken', pageToken);
return get<FilePage>(`/api/imports/google/files?${parameters}`);
},
enabled: status?.connected === true,
});
const metadata = useQuery({
queryKey: ['google-sheets', 'metadata', spreadsheetId],
queryFn: () => get<SpreadsheetMetadata>(`/api/imports/google/spreadsheets/${encodeURIComponent(spreadsheetId)}/sheets`),
enabled: Boolean(spreadsheetId),
});
const load = useMutation({
mutationFn: () => post<GoogleParsedTable>('/api/imports/google/table', {
spreadsheetId,
sheetId: Number(sheetId),
range,
}),
onSuccess: onLoaded,
});
if (statusLoading) return <Skeleton className="h-44" />;
if (!status?.configured) {
return <Card><EmptyState icon={<FileSpreadsheet className="size-8" />} title="Google Sheets is not configured" description="An operator must configure the Google OAuth client and PIG settings encryption key before connecting." /></Card>;
}
if (!status.connected) {
return (
<Card>
<CardHeader><CardTitle className="text-base">Connect Google Sheets</CardTitle></CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted">PIG requests read-only spreadsheet values and Drive metadata only when you start an import. Tokens remain encrypted on the server.</p>
<Button variant="primary" disabled={connect.isPending} onClick={() => connect.mutate()}>
{connect.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <ExternalLink data-icon="inline-start" aria-hidden />}
Connect Google
</Button>
{connect.isError ? <ErrorText error={connect.error} /> : null}
</CardContent>
</Card>
);
}
const selectedSheet = metadata.data?.sheets.find((sheet) => String(sheet.sheetId) === sheetId);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 rounded-lg border border-border bg-surface-2 p-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="text-sm font-medium">Google Sheets connected</p><p className="text-xs text-muted">{status.connectedAt ? `Connected ${relativeTime(status.connectedAt)}` : 'Encrypted server-side connection'}</p></div>
<Button variant="outline" disabled={disconnect.isPending} onClick={() => disconnect.mutate()}><Unplug data-icon="inline-start" aria-hidden />Disconnect</Button>
</div>
{disconnect.isError ? <ErrorText error={disconnect.error} /> : null}
<Card>
<CardHeader><CardTitle className="text-base">Choose a spreadsheet</CardTitle></CardHeader>
<CardContent className="flex flex-col gap-4">
<form className="flex gap-2" onSubmit={(event) => {
event.preventDefault();
setSearch(searchDraft.trim());
setPageToken(null);
setPreviousTokens([]);
}}>
<Input value={searchDraft} onChange={(event) => setSearchDraft(event.target.value)} placeholder="Search spreadsheet names" />
<Button type="submit" variant="outline"><Search data-icon="inline-start" aria-hidden />Search</Button>
</form>
{files.isLoading ? <Skeleton className="h-40" /> : files.isError ? <ErrorText error={files.error} /> : files.data?.files.length === 0 ? <EmptyState title="No spreadsheets found" description="Try another name or confirm this Google account can see the spreadsheet." /> : (
<div className="grid gap-2 sm:grid-cols-2">
{files.data?.files.map((file) => (
<button key={file.id} type="button" onClick={() => { setSpreadsheetId(file.id); setSheetId(''); }} className={spreadsheetId === file.id ? 'tap min-w-0 rounded-lg border border-accent bg-accent-subtle p-3 text-left' : 'tap min-w-0 rounded-lg border border-border p-3 text-left hover:bg-surface-2'}>
<p className="truncate text-sm font-medium">{file.name}</p>
<p className="mt-1 text-xs text-muted">{file.modifiedTime ? `Modified ${relativeTime(file.modifiedTime)}` : 'Modified time unavailable'}</p>
</button>
))}
</div>
)}
{files.data?.incomplete ? <p role="alert" className="text-xs text-warning">Google reported an incomplete Drive search. Narrow the spreadsheet name and search again.</p> : null}
<div className="flex items-center justify-between">
<Button variant="outline" disabled={previousTokens.length === 0} onClick={() => {
setPreviousTokens((tokens) => {
const next = [...tokens];
setPageToken(next.pop() ?? null);
return next;
});
}}><ChevronLeft data-icon="inline-start" aria-hidden />Previous</Button>
<Badge tone="neutral">Up to 50 per page</Badge>
<Button variant="outline" disabled={!files.data?.nextPageToken} onClick={() => {
if (!files.data?.nextPageToken) return;
setPreviousTokens((tokens) => [...tokens, pageToken]);
setPageToken(files.data.nextPageToken);
}}>Next<ChevronRight data-icon="inline-end" aria-hidden /></Button>
</div>
</CardContent>
</Card>
{spreadsheetId ? (
<Card>
<CardHeader><CardTitle className="text-base">Choose a sheet and bounded range</CardTitle><p className="text-xs text-muted">A rectangular A1 range must include one header row and at most 2,000 data rows by 100 columns.</p></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm font-medium">Sheet
<Select value={sheetId} onValueChange={setSheetId} disabled={metadata.isLoading}>
<SelectTrigger className="h-11"><SelectValue placeholder={metadata.isLoading ? 'Loading sheets…' : 'Select visible sheet'} /></SelectTrigger>
<SelectContent><SelectGroup>{metadata.data?.sheets.map((sheet) => <SelectItem key={sheet.sheetId} value={String(sheet.sheetId)}>{sheet.title} · {sheet.rowCount}×{sheet.columnCount}</SelectItem>)}</SelectGroup></SelectContent>
</Select>
</label>
<label className="flex flex-col gap-1.5 text-sm font-medium">A1 range
<Input value={range} onChange={(event) => setRange(event.target.value)} placeholder="A1:H500" autoCapitalize="off" autoCorrect="off" spellCheck={false} />
</label>
{metadata.isError ? <div className="sm:col-span-2"><ErrorText error={metadata.error} /></div> : null}
{selectedSheet ? <p className="text-xs text-muted sm:col-span-2">Selected grid: {selectedSheet.rowCount} rows × {selectedSheet.columnCount} columns. Range limits are enforced again by the server.</p> : null}
<Button className="sm:col-span-2" variant="primary" disabled={!sheetId || !range.trim() || load.isPending} onClick={() => load.mutate()}>
{load.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <FileSpreadsheet data-icon="inline-start" aria-hidden />}
{load.isPending ? 'Reading bounded cells…' : 'Use this range'}
</Button>
{load.isError ? <div className="sm:col-span-2"><ErrorText error={load.error} /></div> : null}
</CardContent>
</Card>
) : null}
</div>
);
}
function ErrorText({ error }: { error: unknown }) {
return <p role="alert" className="text-sm text-danger">{error instanceof ApiError || error instanceof Error ? error.message : 'Google Sheets request failed.'}</p>;
}