diff --git a/lambda/src/data_hub_lambda/azure_600_gel_doc/process_file.py b/lambda/src/data_hub_lambda/azure_600_gel_doc/process_file.py index b28cff82..80617975 100644 --- a/lambda/src/data_hub_lambda/azure_600_gel_doc/process_file.py +++ b/lambda/src/data_hub_lambda/azure_600_gel_doc/process_file.py @@ -68,6 +68,7 @@ def process_file(run_id: str, filename: str) -> str: s3_bucket=processed_bucket or "", s3_key=png_s3_key, filename=png_file_path.name, + size_bytes=png_file_path.stat().st_size, category="processed", ) diff --git a/lambda/src/data_hub_lambda/spectramax_plate_reader/process_file.py b/lambda/src/data_hub_lambda/spectramax_plate_reader/process_file.py index 5a88eaee..95b94557 100644 --- a/lambda/src/data_hub_lambda/spectramax_plate_reader/process_file.py +++ b/lambda/src/data_hub_lambda/spectramax_plate_reader/process_file.py @@ -71,6 +71,7 @@ def process_file(instrument_id: InstrumentType, run_id: str, filename: str) -> s s3_bucket=processed_bucket or "", s3_key=csv_s3_key, filename=csv_filename, + size_bytes=csv_path.stat().st_size, category="processed", ) diff --git a/web-app/app/globals.css b/web-app/app/globals.css index a04b0e5f..c0af73a7 100644 --- a/web-app/app/globals.css +++ b/web-app/app/globals.css @@ -116,6 +116,11 @@ --sidebar-ring: oklch(0.556 0 0); } +@keyframes table-pending-slide { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(400%); } +} + html .shiki, html .shiki span { color: var(--shiki-light); diff --git a/web-app/app/instruments/[instrumentId]/page.tsx b/web-app/app/instruments/[instrumentId]/page.tsx index 432fa8a8..1381cb47 100644 --- a/web-app/app/instruments/[instrumentId]/page.tsx +++ b/web-app/app/instruments/[instrumentId]/page.tsx @@ -2,10 +2,15 @@ import { InstrumentHeader } from "@/components/instruments/instrument-header"; import { InstrumentRunsToolbar } from "@/components/instruments/instrument-runs-toolbar"; import { InstrumentRunsTable } from "@/components/instruments/runs-table"; import { PaginationNav } from "@/components/pagination-nav"; +import { + TablePendingBoundary, + TablePendingProvider, +} from "@/components/table-pending"; import { buildRunListQuery, getGelDocFilterOptions, getPlateReaderFilterOptions, + getQpcrFilterOptions, } from "@/lib/api/instrument-runs"; import { getInstrumentById } from "@/lib/api/instruments"; import { auth } from "@/lib/auth"; @@ -55,6 +60,7 @@ export default async function InstrumentDetailPage({ imagingMode: filters.imaging_mode ?? undefined, gelWavelength: filters.gel_wavelength ?? undefined, gelColor: filters.gel_color ?? undefined, + dyeChannel: filters.dye_channel ?? undefined, }), ]); @@ -62,12 +68,15 @@ export default async function InstrumentDetailPage({ const isPlateReader = instrument.instrumentType === "plate_reader"; const isGelDoc = instrument.instrumentType === "gel_doc"; + const isQpcr = instrument.instrumentType === "qpcr"; // Fetch distinct metadata values for instrument-specific column filter dropdowns. - const [filterOptions, gelDocFilterOptions] = await Promise.all([ - isPlateReader ? getPlateReaderFilterOptions(instrumentId) : undefined, - isGelDoc ? getGelDocFilterOptions(instrumentId) : undefined, - ]); + const [filterOptions, gelDocFilterOptions, qpcrFilterOptions] = + await Promise.all([ + isPlateReader ? getPlateReaderFilterOptions(instrumentId) : undefined, + isGelDoc ? getGelDocFilterOptions(instrumentId) : undefined, + isQpcr ? getQpcrFilterOptions(instrumentId) : undefined, + ]); const hasFilters = filters.search !== "" || @@ -80,25 +89,31 @@ export default async function InstrumentDetailPage({ filters.capture_type !== null || filters.imaging_mode !== null || filters.gel_wavelength !== null || - filters.gel_color !== null; + filters.gel_color !== null || + filters.dye_channel !== null; return (
- - - + + + + + + +
); } diff --git a/web-app/app/page.tsx b/web-app/app/page.tsx index 9e92529d..5fbb7652 100644 --- a/web-app/app/page.tsx +++ b/web-app/app/page.tsx @@ -5,6 +5,10 @@ import { import { RunsTable } from "@/components/dashboard/runs-table"; import { RunsToolbar } from "@/components/dashboard/runs-toolbar"; import { PaginationNav } from "@/components/pagination-nav"; +import { + TablePendingBoundary, + TablePendingProvider, +} from "@/components/table-pending"; import { getInstruments } from "@/lib/api/dashboard"; import { buildRunListQuery } from "@/lib/api/instrument-runs"; import { auth } from "@/lib/auth"; @@ -65,14 +69,18 @@ export default async function DashboardPage({ - + + - - + + + + + ); } diff --git a/web-app/components/dashboard/runs-toolbar.tsx b/web-app/components/dashboard/runs-toolbar.tsx index f813eb48..799c1d41 100644 --- a/web-app/components/dashboard/runs-toolbar.tsx +++ b/web-app/components/dashboard/runs-toolbar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTablePending } from "@/components/table-pending"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -58,9 +59,11 @@ function resolvePreset(dateFrom: string | null): string { } export function RunsToolbar({ instruments }: { instruments: Instrument[] }) { + const { startTransition } = useTablePending(); const [filters, setFilters] = useQueryStates(dashboardSearchParams, { shallow: false, throttleMs: 300, + startTransition, }); const [instrumentOpen, setInstrumentOpen] = useState(false); diff --git a/web-app/components/instruments/edit-instrument-dialog.tsx b/web-app/components/instruments/edit-instrument-dialog.tsx index b0fd6acb..75f9f1ef 100644 --- a/web-app/components/instruments/edit-instrument-dialog.tsx +++ b/web-app/components/instruments/edit-instrument-dialog.tsx @@ -29,6 +29,8 @@ const TYPE_LABELS: Record = { generic: "Generic", plate_reader: "Plate Reader", gel_doc: "Gel Doc", + qpcr: "qPCR", + tape_station: "TapeStation", }; const INSTRUMENT_TYPE_OPTIONS = VALID_INSTRUMENT_TYPES.map((value) => ({ diff --git a/web-app/components/instruments/instrument-runs-toolbar.tsx b/web-app/components/instruments/instrument-runs-toolbar.tsx index 85f14dd9..f887bc43 100644 --- a/web-app/components/instruments/instrument-runs-toolbar.tsx +++ b/web-app/components/instruments/instrument-runs-toolbar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTablePending } from "@/components/table-pending"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -11,9 +12,12 @@ import { useQueryStates } from "nuqs"; export function InstrumentRunsToolbar() { // shallow: false triggers a server-side re-fetch on every URL change so the // table data stays in sync. throttleMs debounces rapid keystrokes in search. + // startTransition ties the refetch to the table's pending/stale treatment. + const { startTransition } = useTablePending(); const [filters, setFilters] = useQueryStates(instrumentDetailSearchParams, { shallow: false, throttleMs: 300, + startTransition, }); const hasFilters = diff --git a/web-app/components/instruments/runs-table/filterable-column-header.tsx b/web-app/components/instruments/runs-table/filterable-column-header.tsx index 22e54d56..3cc47709 100644 --- a/web-app/components/instruments/runs-table/filterable-column-header.tsx +++ b/web-app/components/instruments/runs-table/filterable-column-header.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTablePending } from "@/components/table-pending"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -21,7 +22,8 @@ type FilterParamKey = | "capture_type" | "imaging_mode" | "gel_wavelength" - | "gel_color"; + | "gel_color" + | "dye_channel"; export function FilterableColumnHeader({ label, @@ -32,9 +34,11 @@ export function FilterableColumnHeader({ paramKey: FilterParamKey; options: string[]; }) { + const { startTransition } = useTablePending(); const [filters, setFilters] = useQueryStates(instrumentDetailSearchParams, { shallow: false, throttleMs: 300, + startTransition, }); const currentValue = filters[paramKey]; diff --git a/web-app/components/instruments/runs-table/index.tsx b/web-app/components/instruments/runs-table/index.tsx index 4f2465f6..2602bf59 100644 --- a/web-app/components/instruments/runs-table/index.tsx +++ b/web-app/components/instruments/runs-table/index.tsx @@ -1,6 +1,7 @@ import type { GelDocFilterOptions, PlateReaderFilterOptions, + QpcrFilterOptions, } from "@/lib/api/instrument-runs"; import type { InstrumentType } from "@/lib/db/schema"; import { SearchX } from "lucide-react"; @@ -8,6 +9,7 @@ import { SearchX } from "lucide-react"; import { DefaultRunsTable } from "./default-runs-table"; import { GelDocRunsTable } from "./gel-doc-runs-table"; import { PlateReaderRunsTable } from "./plate-reader-runs-table"; +import { QpcrRunsTable } from "./qpcr-runs-table"; export type RunRow = { id: string; @@ -39,6 +41,7 @@ export function InstrumentRunsTable({ hasFilters, filterOptions, gelDocFilterOptions, + qpcrFilterOptions, }: { data: RunRow[]; instrumentId: string; @@ -46,6 +49,7 @@ export function InstrumentRunsTable({ hasFilters: boolean; filterOptions?: PlateReaderFilterOptions; gelDocFilterOptions?: GelDocFilterOptions; + qpcrFilterOptions?: QpcrFilterOptions; }) { if (data.length === 0) { return ( @@ -77,6 +81,14 @@ export function InstrumentRunsTable({ filterOptions={gelDocFilterOptions!} /> ); + case "qpcr": + return ( + + ); default: return ; } diff --git a/web-app/components/instruments/runs-table/qpcr-runs-table.tsx b/web-app/components/instruments/runs-table/qpcr-runs-table.tsx new file mode 100644 index 00000000..a2a9321c --- /dev/null +++ b/web-app/components/instruments/runs-table/qpcr-runs-table.tsx @@ -0,0 +1,101 @@ +import { RelativeTime } from "@/components/dashboard/relative-time"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { QpcrFilterOptions } from "@/lib/api/instrument-runs"; +import { getDyeChannelColor } from "@/lib/instrument-colors"; +import { cn, formatBytes } from "@/lib/utils"; + +import type { RunRow } from "."; +import { ClickableRow } from "./clickable-row"; +import { FilterableColumnHeader } from "./filterable-column-header"; +import { MetadataArrayBadges, getMetadataArray } from "./metadata-utils"; +import { RunStatusIcon } from "./run-status-icon"; + +export function QpcrRunsTable({ + data, + instrumentId, + filterOptions, +}: { + data: RunRow[]; + instrumentId: string; + filterOptions: QpcrFilterOptions; +}) { + return ( +
+ + + + Run ID + Files + Total Size + + + + Created + + + + {data.map((row) => { + const isDeleted = row.deleted_at !== null; + const dyeChannels = getMetadataArray(row.metadata, "dye_channels"); + const dyeChannelColors = Object.fromEntries( + dyeChannels.map((ch) => [ch, getDyeChannelColor(ch)]) + ); + return ( + + +
+ + + {row.run_id} + + {isDeleted && ( + + deleted + + )} +
+
+ + {row.file_count} + + + {formatBytes(row.total_size_bytes)} + + + + + + + +
+ ); + })} +
+
+
+ ); +} diff --git a/web-app/components/pagination-nav.tsx b/web-app/components/pagination-nav.tsx index 3b3cbcc3..ff6ba3ad 100644 --- a/web-app/components/pagination-nav.tsx +++ b/web-app/components/pagination-nav.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTablePending } from "@/components/table-pending"; import { Pagination, PaginationContent, @@ -9,6 +10,7 @@ import { PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; +import { cn } from "@/lib/utils"; import { parseAsInteger, useQueryState } from "nuqs"; import type { MouseEvent } from "react"; @@ -43,9 +45,15 @@ export function PaginationNav({ totalPages: number; pageParam: string; }) { + // When used inside a TablePendingProvider, URL updates are wrapped in a + // React transition so the sibling table can render a "stale" treatment + // until the new RSC payload streams in. + const { isPending, isPendingVisible, startTransition } = useTablePending(); const [, setPage] = useQueryState( pageParam, - parseAsInteger.withDefault(1).withOptions({ shallow: false }) + parseAsInteger + .withDefault(1) + .withOptions({ shallow: false, startTransition }) ); if (totalPages <= 1) return null; @@ -60,15 +68,25 @@ export function PaginationNav({ }; } + const atPrev = page <= 1; + const atNext = page >= totalPages; + return ( - + @@ -90,10 +108,8 @@ export function PaginationNav({ = totalPages} - className={ - page >= totalPages ? "pointer-events-none opacity-50" : undefined - } + aria-disabled={atNext} + className={atNext ? "pointer-events-none opacity-50" : undefined} /> diff --git a/web-app/components/runs/report-data-table.tsx b/web-app/components/runs/report-data-table.tsx deleted file mode 100644 index 0b53b016..00000000 --- a/web-app/components/runs/report-data-table.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; - -function formatCell(value: unknown): string { - if (value === null || value === undefined) return "—"; - if (typeof value === "number") { - return Number.isInteger(value) - ? String(value) - : value.toPrecision(6).replace(/\.?0+$/, ""); - } - if (typeof value === "boolean") return value ? "true" : "false"; - if (typeof value === "object") return JSON.stringify(value); - return String(value); -} - -export function ReportDataTable({ data }: { data: unknown }) { - if (!Array.isArray(data) || data.length === 0) return null; - - const rows = data as Record[]; - const columns = Object.keys(rows[0]); - - return ( - - - - - {columns.map((col) => ( - - {col} - - ))} - - - - {rows.map((row, i) => ( - - {columns.map((col) => ( - - {formatCell(row[col])} - - ))} - - ))} - -
- -
- ); -} diff --git a/web-app/components/runs/run-files-section.tsx b/web-app/components/runs/run-files-section.tsx index 7e55cfa8..d2c81d76 100644 --- a/web-app/components/runs/run-files-section.tsx +++ b/web-app/components/runs/run-files-section.tsx @@ -11,9 +11,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Select, @@ -22,26 +20,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; import type { RunFile } from "@/lib/api/instrument-runs"; -import { formatDateTime } from "@/lib/date"; -import { formatBytes } from "@/lib/utils"; -import { Download, Loader2, RotateCw, Search, Upload, X } from "lucide-react"; +import { Download, Loader2, Search, Upload, X } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; +import { RunFilesTable, statusLabel } from "./run-files-table"; type StatusFilter = | "all" @@ -54,91 +38,20 @@ type SortField = "name" | "size" | "date" | "status"; const PENDING_STATUSES = new Set(["detected", "upload_requested"]); -function statusLabel(file: RunFile): string { - if (file.deletedAt !== null) return "Dismissed"; - switch (file.status) { - case "detected": - case "upload_requested": - return "Pending"; - case "uploaded": - return "Uploaded"; - case "processing": - return "Processing"; - case "completed": - return "Completed"; - case "failed": - return "Failed"; - default: - return file.status; - } -} - -function StatusBadge({ file }: { file: RunFile }) { - const label = statusLabel(file); - - switch (label) { - case "Pending": - return ( - - {label} - - ); - case "Uploaded": - return {label}; - case "Processing": - return ( - - - {label} - - ); - case "Completed": - return ( - - {label} - - ); - case "Failed": { - const badge = {label}; - if (!file.errorMessage) return badge; - return ( - - - {badge} - - - {file.errorMessage} - - - ); - } - case "Dismissed": - return ( - - {label} - - ); - default: - return {label}; - } -} - function matchesFilter(file: RunFile, filter: StatusFilter): boolean { if (filter === "all") return true; if (filter === "pending") return PENDING_STATUSES.has(file.status); return file.status === filter; } -function compareFiles(a: RunFile, b: RunFile, field: SortField): number { +// Raw files always come before processed files; the user-selected field +// breaks ties within each category. +function compareByCategory(a: RunFile, b: RunFile): number { + if (a.category === b.category) return 0; + return a.category === "raw" ? -1 : 1; +} + +function compareByField(a: RunFile, b: RunFile, field: SortField): number { switch (field) { case "name": return a.filename.localeCompare(b.filename); @@ -156,6 +69,10 @@ function compareFiles(a: RunFile, b: RunFile, field: SortField): number { } } +function compareFiles(a: RunFile, b: RunFile, field: SortField): number { + return compareByCategory(a, b) || compareByField(a, b, field); +} + export function RunFilesSection({ files, instrumentId, @@ -539,196 +456,23 @@ export function RunFilesSection({ No files match your filters.

) : ( - - - - {!isDeleted && visibleSelectableIds.size > 0 && ( - - - - )} - - File name - - - Size - - - Created - - - Status - - - - - - {filteredFiles.map((file) => { - const isDismissed = file.deletedAt !== null; - const isSelectable = - !isDeleted && file.status === "detected" && !isDismissed; - const isSelected = selectedIds.has(file.id); - const showRowActions = - !isDeleted && - !isDismissed && - file.status === "detected" && - selectedDetectedIds.length === 0; - - return ( - - {!isDeleted && visibleSelectableIds.size > 0 && ( - - {isSelectable ? ( - toggleFile(file.id)} - /> - ) : ( -
- )} - - )} - - - {file.filename} - {[ - "uploaded", - "processing", - "completed", - "failed", - ].includes(file.status) && ( - - - - )} - - - - {formatBytes(file.sizeBytes)} - - - {file.createdAt ? formatDateTime(file.createdAt) : "—"} - - - - - - {showRowActions ? ( -
- - - - - - - - - Dismiss file? - - - - {file.filename} - {" "} - will be soft-deleted. The watcher will skip it - on future scans. - - - - Cancel - handleSingleDismiss(file.id)} - > - Dismiss - - - - -
- ) : ( - !isDismissed && - file.status === "failed" && - file.s3Key !== null && ( - - - - - - - - Reprocess file? - - - - {file.filename} - {" "} - will be sent to the Lambda function for - reprocessing. Any existing report data for - this file will be cleared. - - - - Cancel - handleReprocess(file.id)} - > - Reprocess - - - - - ) - )} -
- - ); - })} - -
+ 0, + onToggleFile: toggleFile, + onToggleAll: toggleAll, + }} + onUpload={handleSingleUpload} + onDismiss={handleSingleDismiss} + onReprocess={handleReprocess} + /> )} {/* Summary footer */} diff --git a/web-app/components/runs/run-files-table.tsx b/web-app/components/runs/run-files-table.tsx new file mode 100644 index 00000000..605e5ec1 --- /dev/null +++ b/web-app/components/runs/run-files-table.tsx @@ -0,0 +1,354 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { RunFile } from "@/lib/api/instrument-runs"; +import { formatDateTime } from "@/lib/date"; +import { formatBytes } from "@/lib/utils"; +import { Download, Loader2, RotateCw, Upload, X } from "lucide-react"; + +const DOWNLOADABLE_STATUSES = new Set([ + "uploaded", + "processing", + "completed", + "failed", +]); + +const REPROCESSABLE_STATUSES = new Set(["completed", "failed"]); + +export function statusLabel(file: RunFile): string { + if (file.deletedAt !== null) return "Dismissed"; + switch (file.status) { + case "detected": + case "upload_requested": + return "Pending"; + case "uploaded": + return "Uploaded"; + case "processing": + return "Processing"; + case "completed": + return "Completed"; + case "failed": + return "Failed"; + default: + return file.status; + } +} + +function StatusBadge({ file }: { file: RunFile }) { + const label = statusLabel(file); + + switch (label) { + case "Pending": + return ( + + {label} + + ); + case "Uploaded": + return {label}; + case "Processing": + return ( + + + {label} + + ); + case "Completed": + return ( + + {label} + + ); + case "Failed": { + const badge = {label}; + if (!file.errorMessage) return badge; + return ( + + + {badge} + + + {file.errorMessage} + + + ); + } + case "Dismissed": + return ( + + {label} + + ); + default: + return {label}; + } +} + +export type RunFilesTableSelection = { + selectedIds: Set; + visibleSelectableIds: Set; + allVisibleSelected: boolean; + someVisibleSelected: boolean; + hasBulkSelection: boolean; + onToggleFile: (id: number) => void; + onToggleAll: () => void; +}; + +export type RunFilesTableProps = { + files: RunFile[]; + isDeleted: boolean; + isPending: boolean; + selection: RunFilesTableSelection; + onUpload: (id: number) => void; + onDismiss: (id: number) => void; + onReprocess: (id: number) => void; +}; + +export function RunFilesTable({ + files, + isDeleted, + isPending, + selection, + onUpload, + onDismiss, + onReprocess, +}: RunFilesTableProps) { + const { + selectedIds, + visibleSelectableIds, + allVisibleSelected, + someVisibleSelected, + hasBulkSelection, + onToggleFile, + onToggleAll, + } = selection; + + const showSelectionColumn = !isDeleted && visibleSelectableIds.size > 0; + + return ( + + + + {showSelectionColumn && ( + + + + )} + + File name + + + Type + + + Size + + + Created + + + Status + + + + + + {files.map((file) => { + const isDismissed = file.deletedAt !== null; + const isSelectable = + !isDeleted && file.status === "detected" && !isDismissed; + const isSelected = selectedIds.has(file.id); + const showRowActions = + !isDeleted && + !isDismissed && + file.status === "detected" && + !hasBulkSelection; + + return ( + + {showSelectionColumn && ( + + {isSelectable ? ( + onToggleFile(file.id)} + /> + ) : ( +
+ )} + + )} + + + {file.filename} + {DOWNLOADABLE_STATUSES.has(file.status) && ( + + + + )} + + + + + {file.category} + + + + {formatBytes(file.sizeBytes)} + + + {file.createdAt ? formatDateTime(file.createdAt) : "—"} + + + + + + {showRowActions ? ( +
+ + + + + + + + Dismiss file? + + + {file.filename} + {" "} + will be soft-deleted. The watcher will skip it on + future scans. + + + + Cancel + onDismiss(file.id)}> + Dismiss + + + + +
+ ) : ( + !isDismissed && + REPROCESSABLE_STATUSES.has(file.status) && + file.s3Key !== null && ( +
+ + + + + + + Reprocess file? + + + {file.filename} + {" "} + will be sent to the Lambda function for + reprocessing. Any existing report data for this + file will be cleared. + + + + Cancel + onReprocess(file.id)} + > + Reprocess + + + + +
+ ) + )} +
+ + ); + })} + +
+ ); +} diff --git a/web-app/components/runs/run-metadata-badges.tsx b/web-app/components/runs/run-metadata-badges.tsx index a0aad6a6..2ab75552 100644 --- a/web-app/components/runs/run-metadata-badges.tsx +++ b/web-app/components/runs/run-metadata-badges.tsx @@ -6,6 +6,7 @@ import { MEASUREMENT_MODE_COLORS, MEASUREMENT_TYPE_COLORS, buildWavelengthColorMap, + getDyeChannelColor, } from "@/lib/instrument-colors"; import { cn } from "@/lib/utils"; @@ -51,6 +52,14 @@ function ColorBadge({ // Plate reader // --------------------------------------------------------------------------- +export function hasPlateReaderMetadata(metadata: Record) { + return Boolean( + getMetadataField(metadata, "wavelength") || + getMetadataField(metadata, "measurement_mode") || + getMetadataField(metadata, "measurement_type") + ); +} + export function PlateReaderRunBadges({ metadata, }: { @@ -87,6 +96,15 @@ export function PlateReaderRunBadges({ // Gel doc // --------------------------------------------------------------------------- +export function hasGelDocMetadata(metadata: Record) { + return Boolean( + getMetadataField(metadata, "capture_type") || + getMetadataField(metadata, "imaging_mode") || + getMetadataArray(metadata, "wavelengths").length || + getMetadataArray(metadata, "colors").length + ); +} + export function GelDocRunBadges({ metadata, }: { @@ -142,6 +160,56 @@ export function GelDocRunBadges({ ); } +// --------------------------------------------------------------------------- +// qPCR +// --------------------------------------------------------------------------- + +export function hasQpcrMetadata(metadata: Record) { + return getMetadataArray(metadata, "dye_channels").length > 0; +} + +export function QpcrRunBadges({ + metadata, +}: { + metadata: Record; +}) { + const dyeChannels = getMetadataArray(metadata, "dye_channels"); + + if (dyeChannels.length === 0) return null; + + return ( + + {dyeChannels.map((ch) => ( + + ))} + + ); +} + +// --------------------------------------------------------------------------- +// TapeStation +// --------------------------------------------------------------------------- + +export function hasTapeStationMetadata(metadata: Record) { + return Boolean(getMetadataField(metadata, "Tape Type")); +} + +export function TapeStationRunBadges({ + metadata, +}: { + metadata: Record; +}) { + const tapeType = getMetadataField(metadata, "Tape Type"); + + if (!tapeType) return null; + + return ( + + + + ); +} + // --------------------------------------------------------------------------- // Default / generic — each key gets a row with outline badge value(s) // --------------------------------------------------------------------------- @@ -161,6 +229,10 @@ function formatLabel(key: string): string { return key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } +export function hasDefaultMetadata(metadata: Record) { + return Object.values(metadata).some((v) => formatBadgeValue(v) !== null); +} + export function DefaultRunBadges({ metadata, }: { diff --git a/web-app/components/runs/run-metadata.tsx b/web-app/components/runs/run-metadata.tsx index 90558565..2178a91f 100644 --- a/web-app/components/runs/run-metadata.tsx +++ b/web-app/components/runs/run-metadata.tsx @@ -2,16 +2,7 @@ import type { ReactNode } from "react"; export function RunMetadata({ children }: { children?: ReactNode }) { if (!children) { - return ( -
-

Metadata

-
-

- No metadata recorded yet -

-
-
- ); + return null; } return ( diff --git a/web-app/components/runs/run-report-section.tsx b/web-app/components/runs/run-report-section.tsx index 289e3d6a..3861d216 100644 --- a/web-app/components/runs/run-report-section.tsx +++ b/web-app/components/runs/run-report-section.tsx @@ -4,6 +4,7 @@ import type { RunFile } from "@/lib/api/instrument-runs"; import { ExternalLink } from "lucide-react"; const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|tiff?)$/i; +const PDF_EXTENSION = /\.pdf$/i; function isImageFile(file: RunFile): boolean { return ( @@ -12,6 +13,12 @@ function isImageFile(file: RunFile): boolean { ); } +function isPdfFile(file: RunFile): boolean { + return ( + file.contentType === "application/pdf" || PDF_EXTENSION.test(file.filename) + ); +} + function ProcessedImagePreview({ file }: { file: RunFile }) { const downloadUrl = `/api/v1/files/${file.id}/download`; @@ -34,12 +41,41 @@ function ProcessedImagePreview({ file }: { file: RunFile }) { ); } +function PdfPreview({ file }: { file: RunFile }) { + const downloadUrl = `/api/v1/files/${file.id}/download`; + + return ( +
+
+

{file.filename}

+ +
+
+