v1 of protocol checker
All checks were successful
Deploy to Server / deploy (push) Successful in 34s
All checks were successful
Deploy to Server / deploy (push) Successful in 34s
This commit is contained in:
@@ -66,6 +66,8 @@ const TABLE_COLUMNS: { label: string; key: keyof ChemicalInventory }[] = [
|
||||
{ label: "Multiple CAS (comma delimited)", key: "multipleCAS" },
|
||||
];
|
||||
|
||||
type ImportColumnMapping = keyof ChemicalInventory | "__skip__";
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function daysUntil(dateStr: string) {
|
||||
@@ -144,7 +146,7 @@ export function Inventory() {
|
||||
const [importStep, setImportStep] = useState<"upload" | "map" | "result">("upload");
|
||||
const [importHeaders, setImportHeaders] = useState<string[]>([]);
|
||||
const [importRows, setImportRows] = useState<string[][]>([]);
|
||||
const [columnMapping, setColumnMapping] = useState<Record<string, keyof ChemicalInventory | "__skip__">>({});
|
||||
const [columnMapping, setColumnMapping] = useState<Record<string, ImportColumnMapping>>({});
|
||||
const [importResult, setImportResult] = useState<{ imported: number; errors: string[] } | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [importError, setImportError] = useState("");
|
||||
@@ -339,7 +341,7 @@ export function Inventory() {
|
||||
return s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
function fuzzyMatchColumn(header: string): keyof ChemicalInventory | "__skip__" {
|
||||
function fuzzyMatchColumn(header: string): ImportColumnMapping {
|
||||
const norm = normalizeHeader(header);
|
||||
if (!norm) return "__skip__";
|
||||
for (const col of TABLE_COLUMNS) {
|
||||
@@ -349,7 +351,7 @@ export function Inventory() {
|
||||
const colNorm = normalizeHeader(col.label);
|
||||
if (colNorm.startsWith(norm) || norm.startsWith(colNorm)) return col.key;
|
||||
}
|
||||
let bestKey: keyof ChemicalInventory | "__skip__" = "__skip__";
|
||||
let bestKey: ImportColumnMapping = "__skip__";
|
||||
let bestScore = 0.55;
|
||||
for (const col of TABLE_COLUMNS) {
|
||||
const colNorm = normalizeHeader(col.label);
|
||||
@@ -430,7 +432,7 @@ export function Inventory() {
|
||||
console.log("[import] headers:", headers, "data rows:", dataRows.length);
|
||||
setImportHeaders(headers);
|
||||
setImportRows(dataRows);
|
||||
const mapping: Record<string, keyof ChemicalInventory | ""> = {};
|
||||
const mapping: Record<string, ImportColumnMapping> = {};
|
||||
for (const h of headers) mapping[h] = fuzzyMatchColumn(h);
|
||||
setColumnMapping(mapping);
|
||||
console.log("[import] advancing to map step");
|
||||
@@ -662,7 +664,7 @@ export function Inventory() {
|
||||
<td className="px-3 py-2">
|
||||
<Select
|
||||
value={columnMapping[header] ?? ""}
|
||||
onValueChange={v => setColumnMapping(m => ({ ...m, [header]: v as keyof ChemicalInventory | "__skip__" }))}
|
||||
onValueChange={v => setColumnMapping(m => ({ ...m, [header]: v as ImportColumnMapping }))}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs w-48">
|
||||
<SelectValue placeholder="— skip —" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { ChangeEvent, DragEvent, KeyboardEvent, useRef, useState } from "react";
|
||||
import { protocolsApi } from "../lib/api";
|
||||
import { Card } from "./ui/card";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -29,9 +29,13 @@ interface ChatMessage {
|
||||
}
|
||||
|
||||
export function ProtocolChecker() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [protocol, setProtocol] = useState("");
|
||||
const [selectedFileName, setSelectedFileName] = useState("");
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [analyzed, setAnalyzed] = useState(false);
|
||||
const [analysisError, setAnalysisError] = useState("");
|
||||
const [issues, setIssues] = useState<SafetyIssue[]>([]);
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
id: "1",
|
||||
@@ -42,56 +46,71 @@ export function ProtocolChecker() {
|
||||
const [currentMessage, setCurrentMessage] = useState("");
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
const mockIssues: SafetyIssue[] = [
|
||||
{
|
||||
type: "critical",
|
||||
category: "PPE Requirements",
|
||||
message: "No mention of glove type for handling concentrated acids. Nitrile gloves are required for HCl handling.",
|
||||
source: "OSHA 29 CFR 1910.132",
|
||||
sourceUrl: "https://www.osha.gov/laws-regs/regulations/standardnumber/1910/1910.132"
|
||||
},
|
||||
{
|
||||
type: "warning",
|
||||
category: "Ventilation",
|
||||
message: "Protocol should specify use of fume hood when working with volatile organic compounds.",
|
||||
source: "OSHA Laboratory Standard 29 CFR 1910.1450",
|
||||
sourceUrl: "https://www.osha.gov/laws-regs/regulations/standardnumber/1910/1910.1450"
|
||||
},
|
||||
{
|
||||
type: "suggestion",
|
||||
category: "Waste Disposal",
|
||||
message: "Consider adding specific waste disposal instructions for halogenated solvents per EPA regulations.",
|
||||
source: "EPA RCRA Guidelines",
|
||||
sourceUrl: "https://www.epa.gov/rcra"
|
||||
},
|
||||
{
|
||||
type: "warning",
|
||||
category: "Emergency Procedures",
|
||||
message: "Include location of nearest eyewash station and emergency shower as required by ANSI Z358.1.",
|
||||
source: "ANSI Z358.1-2014",
|
||||
sourceUrl: "https://www.ansi.org"
|
||||
},
|
||||
{
|
||||
type: "suggestion",
|
||||
category: "Chemical Storage",
|
||||
message: "Specify incompatible chemicals that should not be stored together per NFPA guidelines.",
|
||||
source: "NFPA 45: Fire Protection for Laboratories",
|
||||
sourceUrl: "https://www.nfpa.org"
|
||||
const loadProtocolFile = async (file: File) => {
|
||||
setAnalyzed(false);
|
||||
setAnalysisError("");
|
||||
setIssues([]);
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setAnalysisError("File must be 10MB or smaller.");
|
||||
return;
|
||||
}
|
||||
];
|
||||
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
if (extension !== "txt") {
|
||||
setAnalysisError("For now, upload a TXT file or paste protocol text. PDF, DOC, and DOCX text extraction is not wired yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
if (!text.trim()) {
|
||||
setAnalysisError("That file does not contain any readable protocol text.");
|
||||
return;
|
||||
}
|
||||
setProtocol(text);
|
||||
setSelectedFileName(file.name);
|
||||
} catch {
|
||||
setAnalysisError("Could not read that file. Try saving it as a plain TXT file and upload again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) await loadProtocolFile(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const handleUploadKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files?.[0];
|
||||
if (file) await loadProtocolFile(file);
|
||||
};
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!protocol.trim()) return;
|
||||
setIsAnalyzing(true);
|
||||
setAnalyzed(false);
|
||||
setAnalysisError("");
|
||||
setIssues([]);
|
||||
try {
|
||||
const saved = await protocolsApi.createFromText(
|
||||
`Protocol ${new Date().toLocaleDateString()}`,
|
||||
selectedFileName || `Protocol ${new Date().toLocaleDateString()}`,
|
||||
protocol
|
||||
);
|
||||
await protocolsApi.saveAnalysis(saved.id, mockIssues);
|
||||
const analyzedProtocol = await protocolsApi.analyze(saved.id);
|
||||
setIssues(analyzedProtocol.analysis_results || []);
|
||||
setAnalyzed(true);
|
||||
} catch (err) {
|
||||
console.error("Failed to save protocol:", err);
|
||||
setAnalysisError(err instanceof Error ? err.message : "Failed to analyze protocol");
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
@@ -162,9 +181,9 @@ export function ProtocolChecker() {
|
||||
}
|
||||
};
|
||||
|
||||
const criticalCount = mockIssues.filter(i => i.type === "critical").length;
|
||||
const warningCount = mockIssues.filter(i => i.type === "warning").length;
|
||||
const suggestionCount = mockIssues.filter(i => i.type === "suggestion").length;
|
||||
const criticalCount = issues.filter(i => i.type === "critical").length;
|
||||
const warningCount = issues.filter(i => i.type === "warning").length;
|
||||
const suggestionCount = issues.filter(i => i.type === "suggestion").length;
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
@@ -196,10 +215,29 @@ export function ProtocolChecker() {
|
||||
<p className="text-muted-foreground">Paste your protocol text or upload a file</p>
|
||||
</div>
|
||||
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-8 text-center mb-4 hover:border-primary transition-colors cursor-pointer">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".txt,text/plain"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="border-2 border-dashed border-border rounded-lg p-8 text-center mb-4 hover:border-primary transition-colors cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onKeyDown={handleUploadKeyDown}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-foreground mb-1">Click to upload or drag and drop</p>
|
||||
<p className="text-muted-foreground">PDF, DOC, DOCX, TXT (max 10MB)</p>
|
||||
<p className="text-muted-foreground">TXT files for now (max 10MB)</p>
|
||||
{selectedFileName && (
|
||||
<p className="text-primary mt-2">{selectedFileName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative mb-4">
|
||||
@@ -241,6 +279,15 @@ Example:
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{analysisError && (
|
||||
<Alert className="mt-4 border-red-200 bg-red-50">
|
||||
<AlertTriangle className="h-4 w-4 text-red-600" />
|
||||
<AlertDescription className="text-red-700">
|
||||
{analysisError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Results */}
|
||||
@@ -258,7 +305,12 @@ Example:
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{mockIssues.map((issue, idx) => {
|
||||
{issues.length === 0 && (
|
||||
<p className="text-muted-foreground">
|
||||
No safety issues were returned for this protocol.
|
||||
</p>
|
||||
)}
|
||||
{issues.map((issue, idx) => {
|
||||
const colors = getIssueColor(issue.type);
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -60,12 +60,10 @@ function Calendar({
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ className, ...props }) => (
|
||||
<ChevronLeft className={cn("size-4", className)} {...props} />
|
||||
),
|
||||
IconRight: ({ className, ...props }) => (
|
||||
<ChevronRight className={cn("size-4", className)} {...props} />
|
||||
),
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
const Icon = orientation === "left" ? ChevronLeft : ChevronRight;
|
||||
return <Icon className={cn("size-4", className)} {...props} />;
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user