diff --git a/src/app/dashboard/[teamId]/_components/dashboard-metric-card.tsx b/src/app/dashboard/[teamId]/_components/dashboard-metric-card.tsx index d23dbeca..59177635 100644 --- a/src/app/dashboard/[teamId]/_components/dashboard-metric-card.tsx +++ b/src/app/dashboard/[teamId]/_components/dashboard-metric-card.tsx @@ -9,6 +9,7 @@ import { ClipboardCheck, Info, Loader2, + Pencil, Settings, X, } from "lucide-react"; @@ -52,6 +53,7 @@ export function DashboardMetricCard({ teamId, }: DashboardMetricCardProps) { const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); const { confirm } = useConfirmation(); const utils = api.useUtils(); const { isProcessing, getError } = useDashboardCharts(teamId); @@ -62,6 +64,7 @@ export function DashboardMetricCard({ const error = getError(metricId); const isIntegrationMetric = !!metric.integration?.providerId; + const isGoogleSheets = metric.templateId?.startsWith("gsheets-") ?? false; const chartTransform = dashboardChart.chartConfig as ChartTransformResult | null; const hasChartData = !!chartTransform?.chartData?.length; @@ -119,6 +122,18 @@ export function DashboardMetricCard({ toast.error("Update failed", { description: err.message }), }); + const updateConfigMutation = + api.metric.updateEndpointConfigAndRegenerate.useMutation({ + onMutate: () => setOptimisticProcessing(metricId), + onSuccess: () => { + setIsEditMode(false); + toast.success("Range updated, refreshing data..."); + void utils.dashboard.getDashboardCharts.invalidate({ teamId }); + }, + onError: (err) => + toast.error("Update failed", { description: err.message }), + }); + // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- @@ -170,6 +185,28 @@ export function DashboardMetricCard({ [metricId, regenerateChartMutation], ); + const handleUpdateConfig = useCallback( + (newDataRange: string) => { + const currentConfig = metric.endpointConfig as Record; + updateConfigMutation.mutate({ + metricId, + endpointConfig: { + ...currentConfig, + DATA_RANGE: newDataRange, + }, + }); + }, + [metricId, metric.endpointConfig, updateConfigMutation], + ); + + // Reset edit mode when drawer closes + const handleDrawerOpenChange = useCallback((open: boolean) => { + setIsDrawerOpen(open); + if (!open) { + setIsEditMode(false); + } + }, []); + // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- @@ -238,7 +275,7 @@ export function DashboardMetricCard({ ); return ( - + {cardContent} @@ -301,6 +338,16 @@ export function DashboardMetricCard({ )} + {isGoogleSheets && !isEditMode && !processing && ( + + )} + )} + + + + + + {/* Table */} +
+ {isLoading ? ( +
+ +
+ ) : paginatedData.length > 0 ? ( +
+ +
+ + + + + # + + {Array.from({ length: maxCols }).map((_, colIndex) => ( + +
+ + handleColumnCheckbox(colIndex, checked === true) + } + aria-label={`Select column ${columnToLetter(colIndex)}`} + className="h-3.5 w-3.5" + /> + {columnToLetter(colIndex)} +
+
+ ))} +
+
+ + {paginatedData.map((row, localRowIndex) => { + const actualRowIndex = pageStartRowIndex + localRowIndex; + return ( + + +
+ + handleRowCheckbox( + actualRowIndex, + checked === true, + ) + } + aria-label={`Select row ${actualRowIndex + 1}`} + className="h-3.5 w-3.5" + /> + {actualRowIndex + 1} +
+
+ {Array.from({ length: maxCols }).map( + (_, colIndex) => ( + + handleCellMouseDown(actualRowIndex, colIndex) + } + onMouseEnter={() => + handleCellMouseEnter(actualRowIndex, colIndex) + } + > +
+ {row[colIndex] ?? ""} +
+
+ ), + )} +
+ ); + })} +
+
+
+
+ + {/* Pagination controls */} +
+ + Rows {pageStartRowIndex + 1}- + {Math.min(pageStartRowIndex + pageSize, totalRows)} of{" "} + {totalRows} + + +
+ + + {currentPage} / {totalPages} + + +
+ +
+ setGoToRowInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleGoToRow(); + }} + /> + +
+
+
+ ) : ( +
+

No data available

+
+ )} +
+ + {/* Footer info */} +
+

+ Click and drag to select cells, or use checkboxes to select entire + rows/columns. +

+
+ + ); +} diff --git a/src/app/dashboard/[teamId]/_components/drawer/index.ts b/src/app/dashboard/[teamId]/_components/drawer/index.ts index b12f899e..600b65bd 100644 --- a/src/app/dashboard/[teamId]/_components/drawer/index.ts +++ b/src/app/dashboard/[teamId]/_components/drawer/index.ts @@ -1,5 +1,6 @@ export { ChartStatsBar } from "./chart-stats-bar"; export { DrawerTabButtons, type DrawerTab } from "./drawer-tab-buttons"; export { GoalTabContent } from "./goal-tab-content"; +export { GSheetsRangeEditor } from "./gsheets-range-editor"; export { RoleTabContent } from "./role-tab-content"; export { SettingsTabContent } from "./settings-tab-content"; diff --git a/src/app/metric/_components/google-sheets/GoogleSheetsMetricContent.tsx b/src/app/metric/_components/google-sheets/GoogleSheetsMetricContent.tsx index 2d08c9a9..ec4ae573 100644 --- a/src/app/metric/_components/google-sheets/GoogleSheetsMetricContent.tsx +++ b/src/app/metric/_components/google-sheets/GoogleSheetsMetricContent.tsx @@ -25,6 +25,11 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { + type SelectionRange, + columnToLetter, + selectionToA1Notation, +} from "@/lib/integrations/google-sheets-utils"; import { api } from "@/trpc/react"; import type { ContentProps } from "../base/MetricDialogBase"; @@ -35,40 +40,8 @@ function extractSpreadsheetId(url: string): string | null { return match?.[1] ?? null; } -// Convert column index to letter (0 -> A, 1 -> B, etc.) -function columnToLetter(col: number): string { - let letter = ""; - let temp = col; - while (temp >= 0) { - letter = String.fromCharCode((temp % 26) + 65) + letter; - temp = Math.floor(temp / 26) - 1; - } - return letter; -} - -// Convert selection to A1 notation -function selectionToA1Notation( - sheetName: string, - startRow: number, - startCol: number, - endRow: number, - endCol: number, -): string { - const startColLetter = columnToLetter(startCol); - const endColLetter = columnToLetter(endCol); - // Rows are 1-indexed in A1 notation - return `${sheetName}!${startColLetter}${startRow + 1}:${endColLetter}${endRow + 1}`; -} - const TEMPLATE_ID = "gsheets-data"; -interface SelectionRange { - startRow: number; - startCol: number; - endRow: number; - endCol: number; -} - export function GoogleSheetsMetricContent({ connection, onSubmit, diff --git a/src/app/metric/_components/manual/ManualMetricDialog.tsx b/src/app/metric/_components/manual/ManualMetricDialog.tsx index 6221bc98..03681242 100644 --- a/src/app/metric/_components/manual/ManualMetricDialog.tsx +++ b/src/app/metric/_components/manual/ManualMetricDialog.tsx @@ -42,7 +42,10 @@ export function ManualMetricDialog({ {trigger && {trigger}} - Create Manual Metric + Create Manual KPI +

+ Track metrics that you update manually on a regular cadence +

A, 1 -> B, 25 -> Z, 26 -> AA, etc.) + */ +export function columnToLetter(col: number): string { + let letter = ""; + let temp = col; + while (temp >= 0) { + letter = String.fromCharCode((temp % 26) + 65) + letter; + temp = Math.floor(temp / 26) - 1; + } + return letter; +} + +/** + * Convert selection coordinates to A1 notation (e.g., "Sheet1!A1:B10") + */ +export function selectionToA1Notation( + sheetName: string, + startRow: number, + startCol: number, + endRow: number, + endCol: number, +): string { + const startColLetter = columnToLetter(startCol); + const endColLetter = columnToLetter(endCol); + // Rows are 1-indexed in A1 notation + return `${sheetName}!${startColLetter}${startRow + 1}:${endColLetter}${endRow + 1}`; +} + +/** + * Parse A1 notation range to extract row/column bounds + * Example: "Sheet1!B3:E20" -> { startRow: 2, startCol: 1, endRow: 19, endCol: 4 } + */ +export function parseA1Notation(range: string): { + sheetName: string; + startRow: number; + startCol: number; + endRow: number; + endCol: number; +} | null { + // Match pattern: SheetName!A1:B2 + const match = /^(.+)!([A-Z]+)(\d+):([A-Z]+)(\d+)$/.exec(range); + if (!match) return null; + + const [, sheetName, startColStr, startRowStr, endColStr, endRowStr] = match; + if (!sheetName || !startColStr || !startRowStr || !endColStr || !endRowStr) { + return null; + } + + return { + sheetName, + startRow: parseInt(startRowStr, 10) - 1, // Convert to 0-indexed + startCol: letterToColumn(startColStr), + endRow: parseInt(endRowStr, 10) - 1, // Convert to 0-indexed + endCol: letterToColumn(endColStr), + }; +} + +/** + * Convert column letter to index (A -> 0, B -> 1, Z -> 25, AA -> 26, etc.) + */ +export function letterToColumn(letter: string): number { + let col = 0; + for (let i = 0; i < letter.length; i++) { + col = col * 26 + (letter.charCodeAt(i) - 64); + } + return col - 1; // Convert to 0-indexed +} + +export interface SelectionRange { + startRow: number; + startCol: number; + endRow: number; + endCol: number; +} diff --git a/src/server/api/routers/metric.ts b/src/server/api/routers/metric.ts index 6e5994d1..f4464bd7 100644 --- a/src/server/api/routers/metric.ts +++ b/src/server/api/routers/metric.ts @@ -285,6 +285,78 @@ export const metricRouter = createTRPCRouter({ return { success: true }; }), + /** + * Update endpointConfig and regenerate the metric pipeline. + * Used for editing Google Sheets range selection after initial creation. + * Deletes old data points and transformers, then runs a hard refresh. + */ + updateEndpointConfigAndRegenerate: workspaceProcedure + .input( + z.object({ + metricId: z.string(), + endpointConfig: z.record(z.string()), + }), + ) + .mutation(async ({ ctx, input }) => { + // Verify metric belongs to user's organization + const metric = await getMetricAndVerifyAccess( + ctx.db, + input.metricId, + ctx.workspace.organizationId, + ); + + // Get dashboard chart for transformer deletion + const dashboardChart = await ctx.db.dashboardChart.findFirst({ + where: { metricId: input.metricId }, + select: { id: true }, + }); + + // Delete old data points and transformers in transaction + await ctx.db.$transaction(async (tx) => { + // Delete old data points + await tx.metricDataPoint.deleteMany({ + where: { metricId: input.metricId }, + }); + + // Delete old data ingestion transformer + await tx.dataIngestionTransformer.deleteMany({ + where: { templateId: input.metricId }, + }); + + // Delete old chart transformer + if (dashboardChart) { + await tx.chartTransformer.deleteMany({ + where: { dashboardChartId: dashboardChart.id }, + }); + } + + // Update metric with new endpointConfig and set processing status + await tx.metric.update({ + where: { id: input.metricId }, + data: { + endpointConfig: input.endpointConfig, + refreshStatus: "fetching-api-data", + lastError: null, + }, + }); + }); + + // Invalidate cache so frontend sees processing status + const cacheTags = [`dashboard_org_${ctx.workspace.organizationId}`]; + if (metric.teamId) cacheTags.push(`dashboard_team_${metric.teamId}`); + await invalidateCacheByTags(ctx.db, cacheTags); + + // Trigger pipeline regeneration in background + void runBackgroundTask({ + metricId: input.metricId, + type: "hard-refresh", + organizationId: ctx.workspace.organizationId, + teamId: metric.teamId ?? undefined, + }); + + return { success: true }; + }), + // =========================================================================== // Integration Data Fetching (Single Query for dropdowns AND raw data) // ===========================================================================