e12d27edd1
CI / verify (push) Successful in 3m32s
Reframe each screen around the decisions compute brokers make: sellable capacity, full-cost margin, pipeline movement, contract deadlines, evidence review, staged imports, and controlled agent access. Group the shell by operating domain, strengthen mobile navigation and sheets, add responsive record treatments, and make loading, error, empty, readiness, and retry states explicit. The visual audit exposed sortable table targets and an unnamed file input only after exercising the rendered app, so this commit also pins those accessibility decisions at their actual interaction boundaries. Manrope is self-hosted as a single Latin variable subset to keep the stronger hierarchy without shipping unused font payloads.
196 lines
10 KiB
TypeScript
196 lines
10 KiB
TypeScript
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 and are never returned to this page.</p>
|
||
<Button className="w-full sm:w-auto" 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><div className="mb-1 flex items-center gap-2"><p className="text-sm font-medium">Google Sheets connected</p><Badge tone="neutral">Read only</Badge></div><p className="text-xs text-muted">{status.connectedAt ? `Connected ${relativeTime(status.connectedAt)}` : 'Encrypted server-side connection'} · selecting a range only stages a preview</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 name="spreadsheetSearch" aria-label="Search spreadsheet names" 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" aria-pressed={spreadsheetId === file.id} onClick={() => { setSpreadsheetId(file.id); setSheetId(''); }} className={spreadsheetId === file.id ? 'tap min-w-0 rounded-lg border border-primary 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 name="a1Range" 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>;
|
||
}
|