import { useState } from "react"; import { useClient } from "urql"; import { graphql, type ResultOf, type VariablesOf } from "@/graphql"; import { useToast } from "@/hooks/use-toast"; import { fetchAllPages } from "@/lib/fetch-all-pages"; import { csvTimestamp, downloadCsv, toCsv, type CsvColumn } from "@/lib/to-csv"; /** * CSV export for the Stock Levels dashboard (issue #605). * * Stock Levels is served by the custom `listStockRollupsBySite` resolver (still * cursor-paginated, so `fetchAllPages` works) and its filters are non-standard: * a site tab (`siteId`) and a SKU/name search that resolves to an `itemIds` * whitelist rather than `variables.query`. * * To respect the search filter WITHOUT inheriting the dashboard's 200-item * preview cap (issue #605 requires no truncated exports), we page the matching * item ids in full here, then feed them to the rollup query. Rows are the * site-level rollups — the dashboard's visible columns; per-location breakdown * is a UI drill-down, not a column. */ const StockItemIdsCsvQuery = graphql(` query StockLevelsCsvItemIds($query: ItemQueryInput, $first: Int, $after: String) { items(query: $query, first: $first, after: $after) { pageInfo { hasNextPage endCursor } edges { node { id } } } } `); const StockRollupsCsvQuery = graphql(` query StockLevelsCsvRollups( $itemIds: [String!] $siteId: String $first: Int $after: String $order: [ListStockRollupsBySiteInputOrder] ) { listStockRollupsBySite( itemIds: $itemIds siteId: $siteId first: $first after: $after order: $order ) { pageInfo { hasNextPage endCursor } edges { node { itemSku itemName siteName onHand reserved blocked available } } } } `); type RollupVariables = VariablesOf; type ItemFilterQuery = VariablesOf["query"]; type StockRollupCsvRow = NonNullable< ResultOf["listStockRollupsBySite"] >["edges"][number]["node"]; // Header labels match the Stock Levels dashboard columns. const columns: CsvColumn[] = [ { header: "SKU", value: (r) => r.itemSku }, { header: "Item", value: (r) => r.itemName }, { header: "Site", value: (r) => r.siteName }, { header: "On Hand", value: (r) => r.onHand }, { header: "Reserved", value: (r) => r.reserved }, { header: "Blocked", value: (r) => r.blocked }, { header: "Available", value: (r) => r.available }, ]; export function useExportStockLevelsCsv() { const client = useClient(); const toast = useToast(); const [isExporting, setIsExporting] = useState(false); /** * Export all stock rollups matching the current view. Pass the dashboard's * active filter/sort: the SKU/name search as `itemFilterQuery` (undefined * when the search box is empty), the selected `siteId` (undefined for "All"), * and the current `order`. */ async function exportCsv( itemFilterQuery: ItemFilterQuery, siteId: string | undefined, order: RollupVariables["order"], ) { if (isExporting) return; setIsExporting(true); try { // When a search is active, resolve the FULL matching item-id set (no // 200-item cap) so the export isn't truncated. let itemIds: string[] | undefined; if (itemFilterQuery) { const items = await fetchAllPages( client, StockItemIdsCsvQuery, { query: itemFilterQuery }, (data) => data.items, ); itemIds = items.map((i) => i.id); if (itemIds.length === 0) { toast.error("No stock levels to export"); return; } } const rows = await fetchAllPages( client, StockRollupsCsvQuery, { itemIds, siteId, order }, (data) => data.listStockRollupsBySite, ); if (rows.length === 0) { toast.error("No stock levels to export"); return; } downloadCsv(toCsv(rows, columns), `stock-levels-${csvTimestamp()}.csv`); toast.success(`${rows.length.toLocaleString()} rows exported`); } catch (err) { console.error("CSV export failed:", err); toast.error("Failed to export CSV"); } finally { setIsExporting(false); } } return { exportCsv, isExporting }; }