2026-03-19 05:42:11 +00:00
|
|
|
|
import { useState, useRef, useEffect } from "react";
|
2026-04-01 20:12:19 -05:00
|
|
|
|
import ExcelJS from "exceljs";
|
2026-03-19 05:42:11 +00:00
|
|
|
|
import { chemicalsApi } from "../lib/api";
|
2026-04-04 23:11:51 -05:00
|
|
|
|
import { validateCAS, validateNumber, validatePhoneOrEmail } from "../lib/validators";
|
2026-03-19 05:42:11 +00:00
|
|
|
|
import type { ChemicalInventory } from "../shared/types";
|
2026-03-18 17:10:16 -05:00
|
|
|
|
import { Card } from "./ui/card";
|
|
|
|
|
|
import { Button } from "./ui/button";
|
|
|
|
|
|
import { Input } from "./ui/input";
|
2026-03-20 00:28:45 -05:00
|
|
|
|
import { Label } from "./ui/label";
|
2026-03-18 17:10:16 -05:00
|
|
|
|
import { Badge } from "./ui/badge";
|
2026-03-20 00:28:45 -05:00
|
|
|
|
import {
|
|
|
|
|
|
Camera, Sparkles, Mail, Search, Plus, AlertTriangle,
|
|
|
|
|
|
ChevronDown, ChevronUp, Loader2, Trash2, FlaskConical,
|
2026-04-01 20:39:34 -05:00
|
|
|
|
Table2, FileDown, FileSpreadsheet, Upload, ArrowRight,
|
2026-03-18 17:10:16 -05:00
|
|
|
|
} from "lucide-react";
|
|
|
|
|
|
import {
|
2026-03-20 00:28:45 -05:00
|
|
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
|
2026-03-18 17:10:16 -05:00
|
|
|
|
} from "./ui/dialog";
|
|
|
|
|
|
import {
|
2026-03-20 00:28:45 -05:00
|
|
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
2026-03-18 17:10:16 -05:00
|
|
|
|
} from "./ui/select";
|
|
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
// ── constants ──────────────────────────────────────────────────────────────
|
2026-03-18 17:10:16 -05:00
|
|
|
|
|
|
|
|
|
|
const storageDeviceOptions = [
|
2026-03-20 00:28:45 -05:00
|
|
|
|
"Aerosol Can","Ampule","Bulked Item","Fiber Box","Gas Cylinder",
|
|
|
|
|
|
"Glass Bottle","Metal Can","Metal Drum","Metal Open Drum","Pallet",
|
|
|
|
|
|
"Plastic Bag","Plastic Bottle","Plastic Drum","Plastic Open Drum",
|
2026-03-18 17:10:16 -05:00
|
|
|
|
];
|
|
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
const physicalStates = ["Solid", "Liquid", "Gas"];
|
|
|
|
|
|
|
|
|
|
|
|
const unitOptions = ["mL", "L", "g", "kg", "mg", "oz", "lb", "gal", "mol"];
|
|
|
|
|
|
|
2026-04-02 00:40:43 +00:00
|
|
|
|
const TABLE_COLUMNS: { label: string; key: keyof ChemicalInventory }[] = [
|
|
|
|
|
|
{ label: "PI First Name", key: "piFirstName" },
|
|
|
|
|
|
{ label: "Physical State", key: "physicalState" },
|
|
|
|
|
|
{ label: "Chemical Name", key: "chemicalName" },
|
|
|
|
|
|
{ label: "Bldg Code", key: "bldgCode" },
|
|
|
|
|
|
{ label: "LAB", key: "lab" },
|
|
|
|
|
|
{ label: "Storage Location", key: "storageLocation" },
|
|
|
|
|
|
{ label: "Storage Device", key: "storageDevice" },
|
|
|
|
|
|
{ label: "# of Containers", key: "numberOfContainers" },
|
|
|
|
|
|
{ label: "Amount per Container", key: "amountPerContainer" },
|
|
|
|
|
|
{ label: "Unit of Measure", key: "unitOfMeasure" },
|
|
|
|
|
|
{ label: "CAS #", key: "casNumber" },
|
|
|
|
|
|
{ label: "Chemical Formula", key: "chemicalFormula" },
|
|
|
|
|
|
{ label: "Molecular Weight", key: "molecularWeight" },
|
|
|
|
|
|
{ label: "Vendor", key: "vendor" },
|
|
|
|
|
|
{ label: "Catalog #", key: "catalogNumber" },
|
|
|
|
|
|
{ label: "Found in Catalog", key: "foundInCatalog" },
|
|
|
|
|
|
{ label: "PO#", key: "poNumber" },
|
|
|
|
|
|
{ label: "Receipt Date", key: "receiptDate" },
|
|
|
|
|
|
{ label: "Open Date", key: "openDate" },
|
|
|
|
|
|
{ label: "MAX on Hand", key: "maxOnHand" },
|
|
|
|
|
|
{ label: "Expiration Date", key: "expirationDate" },
|
|
|
|
|
|
{ label: "Contact", key: "contact" },
|
|
|
|
|
|
{ label: "Comments", key: "comments" },
|
|
|
|
|
|
{ label: "Date Entered", key: "dateEntered" },
|
|
|
|
|
|
{ label: "Permit #", key: "permitNumber" },
|
|
|
|
|
|
{ label: "Barcode", key: "barcode" },
|
|
|
|
|
|
{ label: "LAST_CHANGED", key: "lastChanged" },
|
|
|
|
|
|
{ label: "Concentration", key: "concentration" },
|
|
|
|
|
|
{ label: "Chemical Number", key: "chemicalNumber" },
|
|
|
|
|
|
{ label: "Lot Number", key: "lotNumber" },
|
|
|
|
|
|
{ label: "Multiple CAS (comma delimited)", key: "multipleCAS" },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function daysUntil(dateStr: string) {
|
|
|
|
|
|
const today = new Date(); today.setHours(0, 0, 0, 0);
|
|
|
|
|
|
return Math.ceil((new Date(dateStr).getTime() - today.getTime()) / 86400000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fillBarColor(pct: number) {
|
|
|
|
|
|
if (pct < 20) return "bg-red-500";
|
|
|
|
|
|
if (pct < 40) return "bg-amber-500";
|
|
|
|
|
|
return "bg-[#5a9584]";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type UserProfile = { pi_first_name: string; bldg_code: string; lab: string; contact?: string };
|
|
|
|
|
|
|
|
|
|
|
|
function emptyForm(profile?: UserProfile | null): Partial<ChemicalInventory> {
|
|
|
|
|
|
return {
|
|
|
|
|
|
piFirstName: profile?.pi_first_name ?? "",
|
|
|
|
|
|
bldgCode: profile?.bldg_code ?? "",
|
|
|
|
|
|
lab: profile?.lab ?? "",
|
|
|
|
|
|
contact: profile?.contact ?? "",
|
|
|
|
|
|
physicalState: "",
|
|
|
|
|
|
chemicalName: "",
|
|
|
|
|
|
storageLocation: "",
|
|
|
|
|
|
storageDevice: "Glass Bottle",
|
|
|
|
|
|
numberOfContainers: "1",
|
|
|
|
|
|
amountPerContainer: "",
|
|
|
|
|
|
unitOfMeasure: "",
|
|
|
|
|
|
casNumber: "",
|
|
|
|
|
|
chemicalFormula: "",
|
|
|
|
|
|
molecularWeight: "",
|
|
|
|
|
|
vendor: "",
|
|
|
|
|
|
catalogNumber: "",
|
|
|
|
|
|
lotNumber: "",
|
|
|
|
|
|
expirationDate: "",
|
|
|
|
|
|
concentration: "",
|
|
|
|
|
|
percentageFull: undefined,
|
|
|
|
|
|
comments: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── field helper ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function Field({ label, value, required }: { label: string; value?: string | number; required?: boolean }) {
|
|
|
|
|
|
if (!value && value !== 0) return null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className={`text-xs mb-0.5 ${required ? "text-red-500" : "text-muted-foreground"}`}>{label}</p>
|
|
|
|
|
|
<p className="text-sm text-foreground">{value}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── main component ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-03-18 17:10:16 -05:00
|
|
|
|
export function Inventory() {
|
2026-03-20 00:28:45 -05:00
|
|
|
|
const [inventory, setInventory] = useState<ChemicalInventory[]>([]);
|
|
|
|
|
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
|
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
2026-03-18 17:10:16 -05:00
|
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
2026-03-20 00:28:45 -05:00
|
|
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// add/edit dialog
|
|
|
|
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
|
|
|
|
const [editingItem, setEditingItem] = useState<ChemicalInventory | null>(null);
|
|
|
|
|
|
const [form, setForm] = useState<Partial<ChemicalInventory>>(emptyForm());
|
|
|
|
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
|
|
const [formError, setFormError] = useState("");
|
|
|
|
|
|
const [showOptional, setShowOptional] = useState(false);
|
|
|
|
|
|
|
2026-04-02 00:40:43 +00:00
|
|
|
|
// table view dialog
|
|
|
|
|
|
const [tableViewOpen, setTableViewOpen] = useState(false);
|
|
|
|
|
|
|
2026-04-01 20:39:34 -05:00
|
|
|
|
// import dialog
|
|
|
|
|
|
const [importOpen, setImportOpen] = useState(false);
|
|
|
|
|
|
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 [importResult, setImportResult] = useState<{ imported: number; errors: string[] } | null>(null);
|
|
|
|
|
|
const [isImporting, setIsImporting] = useState(false);
|
|
|
|
|
|
const [importError, setImportError] = useState("");
|
|
|
|
|
|
const importFileRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
// scan dialog
|
|
|
|
|
|
const [scanOpen, setScanOpen] = useState(false);
|
2026-03-18 17:10:16 -05:00
|
|
|
|
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
|
|
|
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
|
|
|
|
const [extractedData, setExtractedData] = useState<Partial<ChemicalInventory> | null>(null);
|
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
2026-03-19 05:42:11 +00:00
|
|
|
|
useEffect(() => {
|
2026-03-20 00:28:45 -05:00
|
|
|
|
Promise.all([
|
|
|
|
|
|
chemicalsApi.list(),
|
|
|
|
|
|
fetch("/api/profile", { credentials: "include" }).then(r => r.ok ? r.json() : null),
|
|
|
|
|
|
]).then(([chems, prof]) => {
|
|
|
|
|
|
setInventory(chems);
|
|
|
|
|
|
setProfile(prof);
|
|
|
|
|
|
}).finally(() => setIsLoading(false));
|
2026-03-19 05:42:11 +00:00
|
|
|
|
}, []);
|
2026-03-18 17:10:16 -05:00
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
// ── form helpers ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function openAdd() {
|
|
|
|
|
|
setEditingItem(null);
|
|
|
|
|
|
setForm(emptyForm(profile));
|
|
|
|
|
|
setFormError("");
|
|
|
|
|
|
setShowOptional(false);
|
|
|
|
|
|
setDialogOpen(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openEdit(item: ChemicalInventory) {
|
|
|
|
|
|
setEditingItem(item);
|
|
|
|
|
|
setForm({ ...item });
|
|
|
|
|
|
setFormError("");
|
|
|
|
|
|
setShowOptional(true);
|
|
|
|
|
|
setDialogOpen(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setField(key: keyof ChemicalInventory, value: string | number | undefined) {
|
|
|
|
|
|
setForm(f => ({ ...f, [key]: value }));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSave() {
|
|
|
|
|
|
const required: (keyof ChemicalInventory)[] = [
|
|
|
|
|
|
"piFirstName","physicalState","chemicalName","bldgCode","lab",
|
|
|
|
|
|
"storageLocation","storageDevice","numberOfContainers","amountPerContainer",
|
|
|
|
|
|
"unitOfMeasure","casNumber",
|
|
|
|
|
|
];
|
|
|
|
|
|
const missing = required.filter(k => !form[k]);
|
|
|
|
|
|
if (missing.length) {
|
|
|
|
|
|
setFormError("Please fill in all required fields.");
|
|
|
|
|
|
return;
|
2026-03-18 17:10:16 -05:00
|
|
|
|
}
|
2026-04-04 23:11:51 -05:00
|
|
|
|
if (!validateCAS(String(form.casNumber || ""))) {
|
|
|
|
|
|
setFormError("CAS # must be in the format ##-##-# (e.g. 67-56-1).");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!validateNumber(form.numberOfContainers, { min: 1, integer: true })) {
|
|
|
|
|
|
setFormError("# of containers must be a whole number of 1 or more.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!validateNumber(form.amountPerContainer, { min: 0 })) {
|
|
|
|
|
|
setFormError("Amount per container must be a number.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (form.molecularWeight && !validateNumber(form.molecularWeight, { min: 0 })) {
|
|
|
|
|
|
setFormError("Molecular weight must be a number.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (form.percentageFull != null && !validateNumber(form.percentageFull, { min: 0, max: 100 })) {
|
|
|
|
|
|
setFormError("% full must be between 0 and 100.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (form.contact && !validatePhoneOrEmail(String(form.contact))) {
|
|
|
|
|
|
setFormError("Contact must be a valid phone number or email address.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-20 00:28:45 -05:00
|
|
|
|
setFormError("");
|
|
|
|
|
|
setIsSaving(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (editingItem) {
|
|
|
|
|
|
const updated = await chemicalsApi.update(editingItem.id, form);
|
|
|
|
|
|
setInventory(inv => inv.map(c => c.id === editingItem.id ? updated : c));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const saved = await chemicalsApi.create(form as ChemicalInventory);
|
|
|
|
|
|
setInventory(inv => [saved, ...inv]);
|
2026-03-19 05:42:11 +00:00
|
|
|
|
}
|
2026-03-20 00:28:45 -05:00
|
|
|
|
setDialogOpen(false);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setFormError("Failed to save. Please try again.");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsSaving(false);
|
2026-03-18 17:10:16 -05:00
|
|
|
|
}
|
2026-03-20 00:28:45 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleDelete(id: string) {
|
|
|
|
|
|
if (!confirm("Remove this chemical from inventory?")) return;
|
|
|
|
|
|
await chemicalsApi.remove(id);
|
|
|
|
|
|
setInventory(inv => inv.filter(c => c.id !== id));
|
|
|
|
|
|
if (expandedId === id) setExpandedId(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── scan helpers ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function handlePhotoCapture(e: React.ChangeEvent<HTMLInputElement>) {
|
|
|
|
|
|
const file = e.target.files?.[0];
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
|
reader.onloadend = () => {
|
|
|
|
|
|
setCapturedImage(reader.result as string);
|
|
|
|
|
|
setIsProcessing(true);
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
setExtractedData({
|
|
|
|
|
|
chemicalName: "Methanol", casNumber: "67-56-1", physicalState: "Liquid",
|
|
|
|
|
|
vendor: "Sigma-Aldrich", lotNumber: "SLCD7890", catalogNumber: "34860",
|
|
|
|
|
|
amountPerContainer: "1", unitOfMeasure: "L", numberOfContainers: "1",
|
|
|
|
|
|
storageDevice: "Glass Bottle", chemicalFormula: "CH3OH", molecularWeight: "32.04",
|
|
|
|
|
|
expirationDate: "2026-03-15", concentration: "99.8%", percentageFull: 95,
|
|
|
|
|
|
needsManualEntry: ["piFirstName", "bldgCode", "lab", "storageLocation"],
|
|
|
|
|
|
scannedImage: reader.result as string,
|
|
|
|
|
|
piFirstName: profile?.pi_first_name ?? "",
|
|
|
|
|
|
bldgCode: profile?.bldg_code ?? "",
|
|
|
|
|
|
lab: profile?.lab ?? "",
|
|
|
|
|
|
});
|
|
|
|
|
|
setIsProcessing(false);
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
};
|
|
|
|
|
|
reader.readAsDataURL(file);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleAddFromScan() {
|
|
|
|
|
|
if (!extractedData) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const saved = await chemicalsApi.create({
|
|
|
|
|
|
...extractedData,
|
|
|
|
|
|
piFirstName: extractedData.piFirstName || "",
|
|
|
|
|
|
physicalState: extractedData.physicalState || "",
|
|
|
|
|
|
chemicalName: extractedData.chemicalName || "",
|
|
|
|
|
|
bldgCode: extractedData.bldgCode || "",
|
|
|
|
|
|
lab: extractedData.lab || "",
|
|
|
|
|
|
storageLocation: extractedData.storageLocation || "",
|
|
|
|
|
|
storageDevice: extractedData.storageDevice || "Glass Bottle",
|
|
|
|
|
|
numberOfContainers: extractedData.numberOfContainers || "1",
|
|
|
|
|
|
amountPerContainer: extractedData.amountPerContainer || "",
|
|
|
|
|
|
unitOfMeasure: extractedData.unitOfMeasure || "",
|
|
|
|
|
|
casNumber: extractedData.casNumber || "",
|
|
|
|
|
|
});
|
|
|
|
|
|
setInventory(inv => [saved, ...inv]);
|
|
|
|
|
|
setCapturedImage(null); setExtractedData(null); setScanOpen(false);
|
|
|
|
|
|
} catch (err) { console.error(err); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-02 00:40:43 +00:00
|
|
|
|
// ── export helpers ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function handleDownloadCSV() {
|
|
|
|
|
|
const headers = TABLE_COLUMNS.map(c => c.label);
|
|
|
|
|
|
const rows = filtered.map(item =>
|
|
|
|
|
|
TABLE_COLUMNS.map(c => String(item[c.key] ?? ""))
|
|
|
|
|
|
);
|
|
|
|
|
|
const csv = [headers, ...rows]
|
|
|
|
|
|
.map(row => row.map(cell => `"${cell.replace(/"/g, '""')}"`).join(","))
|
|
|
|
|
|
.join("\n");
|
|
|
|
|
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
|
a.href = url;
|
|
|
|
|
|
a.download = "chemical_inventory.csv";
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 20:12:19 -05:00
|
|
|
|
async function handleDownloadExcel() {
|
|
|
|
|
|
const wb = new ExcelJS.Workbook();
|
|
|
|
|
|
const ws = wb.addWorksheet("Chemical Inventory");
|
|
|
|
|
|
ws.addRow(TABLE_COLUMNS.map(c => c.label));
|
|
|
|
|
|
filtered.forEach(item => ws.addRow(TABLE_COLUMNS.map(c => item[c.key] ?? "")));
|
|
|
|
|
|
const buffer = await wb.xlsx.writeBuffer();
|
|
|
|
|
|
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
|
a.href = url;
|
|
|
|
|
|
a.download = "chemical_inventory.xlsx";
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
2026-04-02 00:40:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 20:39:34 -05:00
|
|
|
|
// ── import helpers ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeHeader(s: string): string {
|
|
|
|
|
|
return s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fuzzyMatchColumn(header: string): keyof ChemicalInventory | "__skip__" {
|
|
|
|
|
|
const norm = normalizeHeader(header);
|
|
|
|
|
|
if (!norm) return "__skip__";
|
|
|
|
|
|
for (const col of TABLE_COLUMNS) {
|
|
|
|
|
|
if (normalizeHeader(col.label) === norm) return col.key;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const col of TABLE_COLUMNS) {
|
|
|
|
|
|
const colNorm = normalizeHeader(col.label);
|
|
|
|
|
|
if (colNorm.startsWith(norm) || norm.startsWith(colNorm)) return col.key;
|
|
|
|
|
|
}
|
|
|
|
|
|
let bestKey: keyof ChemicalInventory | "__skip__" = "__skip__";
|
|
|
|
|
|
let bestScore = 0.55;
|
|
|
|
|
|
for (const col of TABLE_COLUMNS) {
|
|
|
|
|
|
const colNorm = normalizeHeader(col.label);
|
|
|
|
|
|
let matches = 0, ci = 0;
|
|
|
|
|
|
for (const ch of norm) {
|
|
|
|
|
|
while (ci < colNorm.length && colNorm[ci] !== ch) ci++;
|
|
|
|
|
|
if (ci < colNorm.length) { matches++; ci++; }
|
|
|
|
|
|
}
|
|
|
|
|
|
const score = matches / Math.max(norm.length, colNorm.length);
|
|
|
|
|
|
if (score > bestScore) { bestScore = score; bestKey = col.key; }
|
|
|
|
|
|
}
|
|
|
|
|
|
return bestKey;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openImport() {
|
|
|
|
|
|
setImportStep("upload");
|
|
|
|
|
|
setImportHeaders([]);
|
|
|
|
|
|
setImportRows([]);
|
|
|
|
|
|
setColumnMapping({});
|
|
|
|
|
|
setImportResult(null);
|
|
|
|
|
|
setImportError("");
|
|
|
|
|
|
setImportOpen(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleImportFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
|
|
|
|
|
const file = e.target.files?.[0];
|
|
|
|
|
|
console.log("[import] file selected:", file?.name, file?.type, file?.size);
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
e.target.value = "";
|
|
|
|
|
|
setImportError("");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
let allRows: string[][];
|
|
|
|
|
|
|
|
|
|
|
|
if (file.name.toLowerCase().endsWith(".csv")) {
|
|
|
|
|
|
console.log("[import] parsing as CSV");
|
|
|
|
|
|
const text = await file.text();
|
|
|
|
|
|
console.log("[import] CSV text length:", text.length);
|
|
|
|
|
|
allRows = text.split(/\r?\n/).map(line => {
|
|
|
|
|
|
const row: string[] = [];
|
|
|
|
|
|
let cur = "", inQuote = false;
|
|
|
|
|
|
for (let i = 0; i < line.length; i++) {
|
|
|
|
|
|
const ch = line[i];
|
|
|
|
|
|
if (ch === '"') {
|
|
|
|
|
|
if (inQuote && line[i + 1] === '"') { cur += '"'; i++; }
|
|
|
|
|
|
else inQuote = !inQuote;
|
|
|
|
|
|
} else if (ch === "," && !inQuote) {
|
|
|
|
|
|
row.push(cur); cur = "";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
cur += ch;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
row.push(cur);
|
|
|
|
|
|
return row;
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log("[import] parsing as XLSX");
|
|
|
|
|
|
const buffer = await file.arrayBuffer();
|
|
|
|
|
|
console.log("[import] arrayBuffer size:", buffer.byteLength);
|
|
|
|
|
|
const wb = new ExcelJS.Workbook();
|
|
|
|
|
|
await wb.xlsx.load(buffer);
|
|
|
|
|
|
console.log("[import] workbook loaded, worksheets:", wb.worksheets.length);
|
|
|
|
|
|
const ws = wb.worksheets[0];
|
|
|
|
|
|
if (!ws) throw new Error("No worksheets found in this file.");
|
|
|
|
|
|
allRows = [];
|
|
|
|
|
|
ws.eachRow(row => {
|
|
|
|
|
|
allRows.push((row.values as ExcelJS.CellValue[]).slice(1).map(v => String(v ?? "")));
|
|
|
|
|
|
});
|
|
|
|
|
|
console.log("[import] parsed rows:", allRows.length);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (allRows.length < 2) {
|
|
|
|
|
|
setImportError("The file appears to be empty or has no data rows.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const headers = allRows[0];
|
|
|
|
|
|
const dataRows = allRows.slice(1).filter(r => r.some(c => c.trim() !== ""));
|
|
|
|
|
|
console.log("[import] headers:", headers, "data rows:", dataRows.length);
|
|
|
|
|
|
setImportHeaders(headers);
|
|
|
|
|
|
setImportRows(dataRows);
|
|
|
|
|
|
const mapping: Record<string, keyof ChemicalInventory | ""> = {};
|
|
|
|
|
|
for (const h of headers) mapping[h] = fuzzyMatchColumn(h);
|
|
|
|
|
|
setColumnMapping(mapping);
|
|
|
|
|
|
console.log("[import] advancing to map step");
|
|
|
|
|
|
setImportStep("map");
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error("[import] error:", err);
|
|
|
|
|
|
setImportError(`Could not read file: ${(err as Error).message}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleImportConfirm() {
|
|
|
|
|
|
setIsImporting(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const rows = importRows.map(row => {
|
|
|
|
|
|
const chemical: Partial<ChemicalInventory> = {};
|
|
|
|
|
|
importHeaders.forEach((header, i) => {
|
|
|
|
|
|
const key = columnMapping[header];
|
|
|
|
|
|
if (key && key !== "__skip__") (chemical as Record<string, string>)[key] = row[i] ?? "";
|
|
|
|
|
|
});
|
|
|
|
|
|
return chemical;
|
|
|
|
|
|
});
|
|
|
|
|
|
const result = await chemicalsApi.import(rows);
|
|
|
|
|
|
setImportResult(result);
|
|
|
|
|
|
setImportStep("result");
|
|
|
|
|
|
if (result.imported > 0) {
|
|
|
|
|
|
const updated = await chemicalsApi.list();
|
|
|
|
|
|
setInventory(updated);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setImportResult({ imported: 0, errors: [(err as Error).message] });
|
|
|
|
|
|
setImportStep("result");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsImporting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
// ── derived ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const filtered = inventory.filter(c =>
|
|
|
|
|
|
c.chemicalName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
|
|
|
|
c.casNumber.includes(searchQuery) ||
|
|
|
|
|
|
c.lab.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
|
|
|
|
(c.storageLocation || "").toLowerCase().includes(searchQuery.toLowerCase())
|
2026-03-18 17:10:16 -05:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
const lowStockCount = inventory.filter(c => c.percentageFull != null && c.percentageFull < 20).length;
|
2026-03-18 17:10:16 -05:00
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
// ── render ───────────────────────────────────────────────────────────────
|
2026-03-18 17:10:16 -05:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-20 00:28:45 -05:00
|
|
|
|
<div className="p-6 max-w-5xl mx-auto">
|
2026-03-18 17:10:16 -05:00
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
{/* Header */}
|
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h1 className="text-foreground mb-1">Chemical Inventory</h1>
|
|
|
|
|
|
<p className="text-muted-foreground text-sm">Track, manage, and monitor your lab chemicals</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-2">
|
2026-04-01 20:39:34 -05:00
|
|
|
|
{/* Import Excel */}
|
|
|
|
|
|
<Button variant="outline" className="gap-2" onClick={openImport}>
|
|
|
|
|
|
<Upload className="w-4 h-4" /> Import Table
|
2026-04-02 00:40:43 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
{/* Add manually */}
|
|
|
|
|
|
<Button onClick={openAdd} className="gap-2 bg-primary hover:bg-primary/90">
|
|
|
|
|
|
<Plus className="w-4 h-4" /> Add Chemical
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Scan */}
|
|
|
|
|
|
<Dialog open={scanOpen} onOpenChange={setScanOpen}>
|
|
|
|
|
|
<DialogTrigger asChild>
|
2026-03-18 17:10:16 -05:00
|
|
|
|
<Button variant="outline" className="gap-2">
|
2026-03-20 00:28:45 -05:00
|
|
|
|
<Camera className="w-4 h-4" /> Scan Label
|
2026-03-18 17:10:16 -05:00
|
|
|
|
</Button>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
</DialogTrigger>
|
|
|
|
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
|
<DialogTitle className="flex items-center gap-2">
|
|
|
|
|
|
<Camera className="w-5 h-5" /> Scan Chemical Label
|
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
<div className="space-y-4 py-2">
|
|
|
|
|
|
{!capturedImage ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="border-2 border-dashed border-border rounded-lg p-12 text-center hover:border-primary transition-colors cursor-pointer bg-muted/30"
|
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Camera className="w-14 h-14 text-muted-foreground mx-auto mb-3" />
|
|
|
|
|
|
<p className="text-foreground mb-1">Click to capture or upload a label photo</p>
|
|
|
|
|
|
<p className="text-muted-foreground text-sm">AI will extract all available information</p>
|
|
|
|
|
|
<input ref={fileInputRef} type="file" accept="image/*" capture="environment"
|
|
|
|
|
|
className="hidden" onChange={handlePhotoCapture} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<img src={capturedImage} alt="Scanned label" className="w-full rounded-lg border border-border" />
|
|
|
|
|
|
{isProcessing ? (
|
|
|
|
|
|
<div className="bg-accent border border-border rounded-lg p-6 text-center">
|
|
|
|
|
|
<Sparkles className="w-8 h-8 text-primary mx-auto mb-2 animate-pulse" />
|
|
|
|
|
|
<p className="text-foreground">Scanning label…</p>
|
|
|
|
|
|
<p className="text-muted-foreground text-sm">Reading all visible information with AI</p>
|
2026-03-18 17:10:16 -05:00
|
|
|
|
</div>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
) : extractedData ? (
|
2026-03-18 17:10:16 -05:00
|
|
|
|
<div className="space-y-4">
|
2026-03-20 00:28:45 -05:00
|
|
|
|
<div className="bg-accent border border-border rounded-lg p-3 flex items-start gap-2">
|
|
|
|
|
|
<Sparkles className="w-4 h-4 text-primary mt-0.5" />
|
|
|
|
|
|
<p className="text-sm text-foreground">Information extracted. Yellow fields need manual entry.</p>
|
2026-03-18 17:10:16 -05:00
|
|
|
|
</div>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
|
{(["piFirstName","bldgCode","lab","storageLocation"] as const).map(k => (
|
|
|
|
|
|
<div key={k} className={`space-y-1 ${extractedData.needsManualEntry?.includes(k) ? "bg-yellow-50 p-2 rounded" : ""}`}>
|
|
|
|
|
|
<Label className="text-red-600 text-xs">{k === "piFirstName" ? "PI First Name" : k === "bldgCode" ? "Building Code" : k === "lab" ? "Lab" : "Storage Location"} *</Label>
|
|
|
|
|
|
<Input value={(extractedData as Record<string,string>)[k] || ""} onChange={e => setExtractedData({ ...extractedData, [k]: e.target.value })} />
|
2026-03-18 17:10:16 -05:00
|
|
|
|
</div>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
))}
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-red-600 text-xs">Chemical Name *</Label>
|
|
|
|
|
|
<Input value={extractedData.chemicalName || ""} onChange={e => setExtractedData({...extractedData, chemicalName: e.target.value})} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-red-600 text-xs">CAS # *</Label>
|
|
|
|
|
|
<Input value={extractedData.casNumber || ""} onChange={e => setExtractedData({...extractedData, casNumber: e.target.value})} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-red-600 text-xs">Physical State *</Label>
|
|
|
|
|
|
<Select value={extractedData.physicalState} onValueChange={v => setExtractedData({...extractedData, physicalState: v})}>
|
|
|
|
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
|
|
|
|
<SelectContent>{physicalStates.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-red-600 text-xs">Storage Device *</Label>
|
|
|
|
|
|
<Select value={extractedData.storageDevice} onValueChange={v => setExtractedData({...extractedData, storageDevice: v})}>
|
|
|
|
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
|
|
|
|
<SelectContent>{storageDeviceOptions.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Amount / Container</Label>
|
|
|
|
|
|
<Input value={extractedData.amountPerContainer || ""} onChange={e => setExtractedData({...extractedData, amountPerContainer: e.target.value})} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Unit</Label>
|
|
|
|
|
|
<Input value={extractedData.unitOfMeasure || ""} onChange={e => setExtractedData({...extractedData, unitOfMeasure: e.target.value})} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Expiration Date</Label>
|
|
|
|
|
|
<Input type="date" value={extractedData.expirationDate || ""} onChange={e => setExtractedData({...extractedData, expirationDate: e.target.value})} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs text-blue-600">% Full</Label>
|
|
|
|
|
|
<Input type="number" min={0} max={100} value={extractedData.percentageFull ?? ""} onChange={e => setExtractedData({...extractedData, percentageFull: parseFloat(e.target.value)})} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Button className="flex-1 bg-primary hover:bg-primary/90" onClick={handleAddFromScan}>Add to Inventory</Button>
|
|
|
|
|
|
<Button variant="outline" onClick={() => { setCapturedImage(null); setExtractedData(null); }}>Retake</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-18 17:10:16 -05:00
|
|
|
|
|
2026-04-01 20:39:34 -05:00
|
|
|
|
{/* Import Excel dialog */}
|
|
|
|
|
|
<Dialog open={importOpen} onOpenChange={setImportOpen}>
|
|
|
|
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
|
<DialogTitle className="flex items-center gap-2">
|
|
|
|
|
|
<Upload className="w-5 h-5" /> Import File
|
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
|
|
{importStep === "upload" && (
|
|
|
|
|
|
<div className="space-y-3 mt-2">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="border-2 border-dashed border-border rounded-lg p-12 text-center hover:border-primary transition-colors cursor-pointer bg-muted/30"
|
|
|
|
|
|
onClick={() => importFileRef.current?.click()}
|
|
|
|
|
|
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add("border-primary"); }}
|
|
|
|
|
|
onDragLeave={e => e.currentTarget.classList.remove("border-primary")}
|
|
|
|
|
|
onDrop={e => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
e.currentTarget.classList.remove("border-primary");
|
|
|
|
|
|
const file = e.dataTransfer.files[0];
|
|
|
|
|
|
if (file) handleImportFileSelect({ target: { files: e.dataTransfer.files, value: "" } } as unknown as React.ChangeEvent<HTMLInputElement>);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<FileSpreadsheet className="w-14 h-14 text-muted-foreground mx-auto mb-3" />
|
|
|
|
|
|
<p className="text-foreground mb-1">Click or drag a file here</p>
|
|
|
|
|
|
<p className="text-muted-foreground text-sm">Supports .xlsx and .csv — columns will be automatically matched to your inventory fields</p>
|
|
|
|
|
|
<input ref={importFileRef} type="file" accept=".xlsx,.xls,.csv" className="hidden" onChange={handleImportFileSelect} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{importError && (
|
|
|
|
|
|
<p className="text-sm text-red-600 flex items-center gap-1">
|
|
|
|
|
|
<AlertTriangle className="w-4 h-4 shrink-0" /> {importError}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{importStep === "map" && (
|
|
|
|
|
|
<div className="space-y-4 mt-2">
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
Review the column mapping below. Adjust any mismatches before importing{" "}
|
|
|
|
|
|
<span className="font-medium text-foreground">{importRows.length} row{importRows.length !== 1 ? "s" : ""}</span>.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="border border-border rounded-lg overflow-hidden">
|
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
|
<thead className="bg-muted/50">
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th className="text-left px-3 py-2 text-muted-foreground font-medium">File Column</th>
|
|
|
|
|
|
<th className="text-left px-3 py-2 text-muted-foreground font-medium w-8"></th>
|
|
|
|
|
|
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Maps To</th>
|
|
|
|
|
|
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Sample</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{importHeaders.map((header, idx) => (
|
|
|
|
|
|
<tr key={header} className="border-t border-border">
|
|
|
|
|
|
<td className="px-3 py-2 text-foreground font-mono text-xs">{header}</td>
|
|
|
|
|
|
<td className="px-1 py-2 text-muted-foreground">
|
|
|
|
|
|
<ArrowRight className="w-3 h-3" />
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-3 py-2">
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={columnMapping[header] ?? ""}
|
|
|
|
|
|
onValueChange={v => setColumnMapping(m => ({ ...m, [header]: v as keyof ChemicalInventory | "__skip__" }))}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger className="h-7 text-xs w-48">
|
|
|
|
|
|
<SelectValue placeholder="— skip —" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="__skip__">— skip —</SelectItem>
|
|
|
|
|
|
{TABLE_COLUMNS.map(col => (
|
|
|
|
|
|
<SelectItem key={col.key} value={col.key}>{col.label}</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-3 py-2 text-muted-foreground text-xs truncate max-w-[120px]">
|
|
|
|
|
|
{importRows[0]?.[idx] ?? ""}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-2 pt-1">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
className="flex-1 bg-primary hover:bg-primary/90 gap-2"
|
|
|
|
|
|
onClick={handleImportConfirm}
|
|
|
|
|
|
disabled={isImporting}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isImporting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
|
|
|
|
|
{isImporting ? "Importing…" : `Import ${importRows.length} row${importRows.length !== 1 ? "s" : ""}`}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button variant="outline" onClick={() => setImportStep("upload")}>Back</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{importStep === "result" && importResult && (
|
|
|
|
|
|
<div className="space-y-4 mt-2">
|
|
|
|
|
|
<div className={`rounded-lg p-4 border ${importResult.imported > 0 ? "bg-accent border-border" : "bg-red-50 border-red-200"}`}>
|
|
|
|
|
|
<p className="font-medium text-foreground">
|
|
|
|
|
|
{importResult.imported > 0
|
|
|
|
|
|
? `Successfully imported ${importResult.imported} chemical${importResult.imported !== 1 ? "s" : ""}.`
|
|
|
|
|
|
: "No rows were imported."}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{importResult.errors.length > 0 && (
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<p className="text-sm font-medium text-red-600 flex items-center gap-1">
|
|
|
|
|
|
<AlertTriangle className="w-4 h-4" /> {importResult.errors.length} row{importResult.errors.length !== 1 ? "s" : ""} failed
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="border border-red-200 rounded-lg bg-red-50 p-3 max-h-40 overflow-y-auto space-y-1">
|
|
|
|
|
|
{importResult.errors.map((e, i) => (
|
|
|
|
|
|
<p key={i} className="text-xs text-red-700 font-mono">{e}</p>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Button className="flex-1 bg-primary hover:bg-primary/90" onClick={() => setImportOpen(false)}>Done</Button>
|
|
|
|
|
|
<Button variant="outline" onClick={openImport}>Import Another File</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
{/* Email receipt banner */}
|
|
|
|
|
|
<Card className="p-4 mb-5 bg-gradient-to-r from-accent to-secondary border-border">
|
|
|
|
|
|
<div className="flex items-start gap-3">
|
|
|
|
|
|
<Mail className="w-4 h-4 text-primary mt-0.5 shrink-0" />
|
|
|
|
|
|
<p className="text-sm text-foreground">
|
|
|
|
|
|
Auto-import receipts: email them to{" "}
|
|
|
|
|
|
<code className="bg-muted px-1.5 py-0.5 rounded text-primary text-xs">inventory@labwise-auto.com</code>
|
|
|
|
|
|
{" "}and we'll extract and add chemicals automatically.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Search + stats */}
|
|
|
|
|
|
<div className="flex items-center gap-3 mb-5">
|
|
|
|
|
|
<div className="relative flex-1">
|
|
|
|
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
|
|
|
|
|
<Input
|
|
|
|
|
|
placeholder="Search by name, CAS, lab, or location…"
|
|
|
|
|
|
className="pl-9"
|
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2 shrink-0 text-sm text-muted-foreground">
|
|
|
|
|
|
<FlaskConical className="w-4 h-4 text-[#5a9584]" />
|
|
|
|
|
|
<span>{inventory.length} chemicals</span>
|
|
|
|
|
|
{lowStockCount > 0 && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className="text-border">·</span>
|
|
|
|
|
|
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
|
|
|
|
|
<span className="text-amber-600">{lowStockCount} low stock</span>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-04-01 20:39:34 -05:00
|
|
|
|
<span className="text-border">·</span>
|
|
|
|
|
|
<Button variant="ghost" size="sm" className="gap-1.5 h-7 px-2 text-muted-foreground bg-background hover:bg-background/80 hover:text-foreground rounded-md" onClick={() => setTableViewOpen(true)}>
|
|
|
|
|
|
<Table2 className="w-3.5 h-3.5" /> View / Export Table
|
|
|
|
|
|
</Button>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-18 17:10:16 -05:00
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
{/* List */}
|
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
|
<div className="flex justify-center py-16">
|
|
|
|
|
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : filtered.length === 0 ? (
|
|
|
|
|
|
<div className="text-center py-16 text-muted-foreground">
|
|
|
|
|
|
{inventory.length === 0
|
|
|
|
|
|
? "No chemicals yet. Add one to get started."
|
|
|
|
|
|
: "No chemicals match your search."}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
{filtered.map(item => {
|
|
|
|
|
|
const isExpanded = expandedId === item.id;
|
|
|
|
|
|
const days = item.expirationDate ? daysUntil(item.expirationDate) : null;
|
|
|
|
|
|
const expired = days !== null && days < 0;
|
|
|
|
|
|
const expiringSoon = days !== null && days >= 0 && days <= 30;
|
|
|
|
|
|
const lowStock = item.percentageFull != null && item.percentageFull < 20;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Card key={item.id} className={`overflow-hidden transition-shadow ${isExpanded ? "shadow-md" : "hover:shadow-sm"}`}>
|
|
|
|
|
|
{/* Summary row */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="w-full text-left px-5 py-4 flex items-center gap-4"
|
|
|
|
|
|
onClick={() => setExpandedId(isExpanded ? null : item.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* Name + state */}
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
<span className="font-medium text-foreground">{item.chemicalName}</span>
|
|
|
|
|
|
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{item.physicalState}</span>
|
|
|
|
|
|
{expired && <Badge className="bg-red-100 text-red-700 border-red-200 border text-xs py-0">Expired</Badge>}
|
|
|
|
|
|
{expiringSoon && !expired && <Badge className="bg-amber-100 text-amber-700 border-amber-200 border text-xs py-0">Exp. {days}d</Badge>}
|
|
|
|
|
|
{lowStock && <Badge className="bg-red-100 text-red-700 border-red-200 border text-xs py-0">Low stock</Badge>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-sm text-muted-foreground mt-0.5">{item.lab} · {item.storageLocation || "—"}</p>
|
|
|
|
|
|
</div>
|
2026-03-18 17:10:16 -05:00
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
{/* Amount */}
|
|
|
|
|
|
<div className="text-sm text-muted-foreground shrink-0 hidden sm:block">
|
|
|
|
|
|
{item.numberOfContainers} × {item.amountPerContainer} {item.unitOfMeasure}
|
|
|
|
|
|
</div>
|
2026-03-18 17:10:16 -05:00
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
{/* Fill bar */}
|
|
|
|
|
|
{item.percentageFull != null && (
|
|
|
|
|
|
<div className="flex items-center gap-2 shrink-0 hidden md:flex">
|
|
|
|
|
|
<div className="w-20 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
|
|
|
|
<div className={`h-full rounded-full ${fillBarColor(item.percentageFull)}`} style={{ width: `${item.percentageFull}%` }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="text-xs text-muted-foreground w-8">{item.percentageFull}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Chevron */}
|
|
|
|
|
|
{isExpanded
|
|
|
|
|
|
? <ChevronUp className="w-4 h-4 text-muted-foreground shrink-0" />
|
|
|
|
|
|
: <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Expanded detail */}
|
|
|
|
|
|
{isExpanded && (
|
|
|
|
|
|
<div className="border-t border-border px-5 py-4 bg-secondary/40">
|
|
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-5">
|
|
|
|
|
|
|
|
|
|
|
|
{/* Storage */}
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Storage</p>
|
|
|
|
|
|
<Field label="Building" value={item.bldgCode} required />
|
|
|
|
|
|
<Field label="Lab" value={item.lab} required />
|
|
|
|
|
|
<Field label="Location" value={item.storageLocation} required />
|
|
|
|
|
|
<Field label="Device" value={item.storageDevice} required />
|
|
|
|
|
|
<Field label="Containers" value={item.numberOfContainers} required />
|
|
|
|
|
|
<Field label="Amount / Container" value={`${item.amountPerContainer} ${item.unitOfMeasure}`} required />
|
|
|
|
|
|
{item.percentageFull != null && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground mb-1">Fill level</p>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
|
|
|
|
|
<div className={`h-full rounded-full ${fillBarColor(item.percentageFull)}`} style={{ width: `${item.percentageFull}%` }} />
|
2026-03-18 17:10:16 -05:00
|
|
|
|
</div>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
<span className="text-sm font-medium">{item.percentageFull}%</span>
|
2026-03-18 17:10:16 -05:00
|
|
|
|
</div>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-03-18 17:10:16 -05:00
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
{/* Chemical info */}
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Chemical</p>
|
|
|
|
|
|
<Field label="CAS #" value={item.casNumber} required />
|
|
|
|
|
|
<Field label="Physical State" value={item.physicalState} required />
|
|
|
|
|
|
<Field label="Formula" value={item.chemicalFormula} />
|
|
|
|
|
|
<Field label="Molecular Weight" value={item.molecularWeight ? `${item.molecularWeight} g/mol` : undefined} />
|
|
|
|
|
|
<Field label="Concentration" value={item.concentration} />
|
|
|
|
|
|
<Field label="PI" value={item.piFirstName} />
|
|
|
|
|
|
<Field label="Contact" value={item.contact} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Procurement */}
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Procurement</p>
|
|
|
|
|
|
<Field label="Vendor" value={item.vendor} />
|
|
|
|
|
|
<Field label="Catalog #" value={item.catalogNumber} />
|
|
|
|
|
|
<Field label="Lot #" value={item.lotNumber} />
|
|
|
|
|
|
{item.expirationDate && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground mb-0.5">Expiration</p>
|
|
|
|
|
|
<p className={`text-sm font-medium ${expired ? "text-red-600" : expiringSoon ? "text-amber-600" : "text-foreground"}`}>
|
|
|
|
|
|
{item.expirationDate}
|
|
|
|
|
|
{expired && ` (expired ${Math.abs(days!)}d ago)`}
|
|
|
|
|
|
{expiringSoon && ` (${days}d left)`}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Field label="Barcode" value={item.barcode} />
|
|
|
|
|
|
{item.comments && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground mb-0.5">Comments</p>
|
|
|
|
|
|
<p className="text-sm text-foreground whitespace-pre-wrap">{item.comments}</p>
|
2026-03-18 17:10:16 -05:00
|
|
|
|
</div>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
)}
|
2026-03-18 17:10:16 -05:00
|
|
|
|
</div>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
</div>
|
2026-03-18 17:10:16 -05:00
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
<div className="flex gap-2 pt-2 border-t border-border">
|
|
|
|
|
|
<Button size="sm" variant="outline" onClick={() => openEdit(item)}>Edit</Button>
|
|
|
|
|
|
<Button size="sm" variant="ghost" className="text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => handleDelete(item.id)}>
|
|
|
|
|
|
<Trash2 className="w-4 h-4 mr-1" /> Delete
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-03-18 17:10:16 -05:00
|
|
|
|
</div>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
)}
|
2026-03-18 17:10:16 -05:00
|
|
|
|
|
2026-04-02 00:40:43 +00:00
|
|
|
|
{/* Table View dialog */}
|
|
|
|
|
|
<Dialog open={tableViewOpen} onOpenChange={setTableViewOpen}>
|
2026-04-02 00:49:40 +00:00
|
|
|
|
<DialogContent className="sm:max-w-[95vw] w-[95vw] h-[90vh] flex flex-col p-0">
|
2026-04-02 00:40:43 +00:00
|
|
|
|
<DialogHeader className="px-5 pt-5 pb-3 shrink-0 border-b border-border">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<DialogTitle className="flex items-center gap-2">
|
|
|
|
|
|
<Table2 className="w-5 h-5" /> Inventory Table
|
|
|
|
|
|
<span className="text-sm font-normal text-muted-foreground ml-1">
|
|
|
|
|
|
({filtered.length} {filtered.length === 1 ? "chemical" : "chemicals"})
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
|
<div className="flex gap-2 mr-6">
|
|
|
|
|
|
<Button size="sm" variant="outline" className="gap-1.5" onClick={handleDownloadCSV}>
|
|
|
|
|
|
<FileDown className="w-4 h-4" /> CSV
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button size="sm" variant="outline" className="gap-1.5" onClick={handleDownloadExcel}>
|
|
|
|
|
|
<FileSpreadsheet className="w-4 h-4" /> Excel
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DialogHeader>
|
2026-04-01 20:12:19 -05:00
|
|
|
|
<div className="table-scroll flex-1">
|
2026-04-02 00:40:43 +00:00
|
|
|
|
<table className="text-xs border-collapse min-w-max">
|
|
|
|
|
|
<thead className="sticky top-0 z-10 bg-muted">
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
{TABLE_COLUMNS.map(col => (
|
|
|
|
|
|
<th
|
|
|
|
|
|
key={col.key}
|
|
|
|
|
|
className="text-left px-3 py-2 font-semibold text-muted-foreground whitespace-nowrap border-b border-r border-border last:border-r-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
{col.label}
|
|
|
|
|
|
</th>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{filtered.map((item, i) => (
|
|
|
|
|
|
<tr key={item.id} className={i % 2 === 0 ? "bg-background" : "bg-muted/30"}>
|
|
|
|
|
|
{TABLE_COLUMNS.map(col => (
|
|
|
|
|
|
<td
|
|
|
|
|
|
key={col.key}
|
|
|
|
|
|
className="px-3 py-1.5 whitespace-nowrap border-b border-r border-border/50 last:border-r-0 max-w-[200px] overflow-hidden text-ellipsis"
|
|
|
|
|
|
title={String(item[col.key] ?? "")}
|
|
|
|
|
|
>
|
|
|
|
|
|
{String(item[col.key] ?? "")}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{filtered.length === 0 && (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td colSpan={TABLE_COLUMNS.length} className="text-center py-10 text-muted-foreground">
|
|
|
|
|
|
No chemicals to display.
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
{/* Add / Edit dialog */}
|
|
|
|
|
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
|
|
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
|
<DialogTitle>{editingItem ? "Edit Chemical" : "Add Chemical"}</DialogTitle>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-5 py-2">
|
|
|
|
|
|
{/* Required fields */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">Required fields</p>
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Chemical Name <span className="text-red-500">*</span></Label>
|
|
|
|
|
|
<Input value={form.chemicalName || ""} onChange={e => setField("chemicalName", e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">CAS # <span className="text-red-500">*</span></Label>
|
2026-04-04 23:11:51 -05:00
|
|
|
|
<Input
|
|
|
|
|
|
value={form.casNumber || ""}
|
|
|
|
|
|
onChange={e => setField("casNumber", e.target.value)}
|
|
|
|
|
|
placeholder="e.g. 67-56-1"
|
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
|
pattern="\d{2,7}-\d{2}-\d"
|
|
|
|
|
|
title="CAS format: digits-digits-digit (e.g. 67-56-1)"
|
|
|
|
|
|
/>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Physical State <span className="text-red-500">*</span></Label>
|
|
|
|
|
|
<Select value={form.physicalState || ""} onValueChange={v => setField("physicalState", v)}>
|
|
|
|
|
|
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
|
|
|
|
|
|
<SelectContent>{physicalStates.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Storage Device <span className="text-red-500">*</span></Label>
|
|
|
|
|
|
<Select value={form.storageDevice || ""} onValueChange={v => setField("storageDevice", v)}>
|
|
|
|
|
|
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
|
|
|
|
|
|
<SelectContent>{storageDeviceOptions.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">PI First Name <span className="text-red-500">*</span></Label>
|
|
|
|
|
|
<Input value={form.piFirstName || ""} onChange={e => setField("piFirstName", e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Building Code <span className="text-red-500">*</span></Label>
|
|
|
|
|
|
<Input value={form.bldgCode || ""} onChange={e => setField("bldgCode", e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Lab <span className="text-red-500">*</span></Label>
|
|
|
|
|
|
<Input value={form.lab || ""} onChange={e => setField("lab", e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Storage Location <span className="text-red-500">*</span></Label>
|
|
|
|
|
|
<Input value={form.storageLocation || ""} onChange={e => setField("storageLocation", e.target.value)} placeholder="e.g. Cabinet A-3" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs"># of Containers <span className="text-red-500">*</span></Label>
|
|
|
|
|
|
<Input type="number" min={1} value={form.numberOfContainers || ""} onChange={e => setField("numberOfContainers", e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Amount / Container <span className="text-red-500">*</span></Label>
|
2026-04-04 23:11:51 -05:00
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={0}
|
|
|
|
|
|
step="any"
|
|
|
|
|
|
value={form.amountPerContainer || ""}
|
|
|
|
|
|
onChange={e => setField("amountPerContainer", e.target.value)}
|
|
|
|
|
|
placeholder="e.g. 500"
|
|
|
|
|
|
/>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1 col-span-2">
|
|
|
|
|
|
<Label className="text-xs">Unit of Measure <span className="text-red-500">*</span></Label>
|
|
|
|
|
|
<Select value={form.unitOfMeasure || ""} onValueChange={v => setField("unitOfMeasure", v)}>
|
|
|
|
|
|
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{unitOptions.map(u => <SelectItem key={u} value={u}>{u}</SelectItem>)}
|
|
|
|
|
|
<SelectItem value="other">Other</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
2026-03-18 17:10:16 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
{/* Optional fields toggle */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
|
|
|
|
|
|
onClick={() => setShowOptional(v => !v)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{showOptional ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
|
|
|
|
{showOptional ? "Hide" : "Show"} optional fields
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{showOptional && (
|
2026-03-18 17:10:16 -05:00
|
|
|
|
<div>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">Optional fields</p>
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Chemical Formula</Label>
|
|
|
|
|
|
<Input value={form.chemicalFormula || ""} onChange={e => setField("chemicalFormula", e.target.value)} placeholder="e.g. CH₃OH" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Molecular Weight</Label>
|
2026-04-04 23:11:51 -05:00
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={0}
|
|
|
|
|
|
step="any"
|
|
|
|
|
|
value={form.molecularWeight || ""}
|
|
|
|
|
|
onChange={e => setField("molecularWeight", e.target.value)}
|
|
|
|
|
|
placeholder="g/mol"
|
|
|
|
|
|
/>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Concentration</Label>
|
|
|
|
|
|
<Input value={form.concentration || ""} onChange={e => setField("concentration", e.target.value)} placeholder="e.g. 99.8%" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">% Full</Label>
|
|
|
|
|
|
<Input type="number" min={0} max={100} value={form.percentageFull ?? ""} onChange={e => setField("percentageFull", e.target.value ? parseFloat(e.target.value) : undefined)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Vendor</Label>
|
|
|
|
|
|
<Input value={form.vendor || ""} onChange={e => setField("vendor", e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Catalog #</Label>
|
|
|
|
|
|
<Input value={form.catalogNumber || ""} onChange={e => setField("catalogNumber", e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Lot #</Label>
|
|
|
|
|
|
<Input value={form.lotNumber || ""} onChange={e => setField("lotNumber", e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Expiration Date</Label>
|
|
|
|
|
|
<Input type="date" value={form.expirationDate || ""} onChange={e => setField("expirationDate", e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Barcode</Label>
|
|
|
|
|
|
<Input value={form.barcode || ""} onChange={e => setField("barcode", e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Label className="text-xs">Contact</Label>
|
2026-04-04 23:11:51 -05:00
|
|
|
|
<Input
|
|
|
|
|
|
value={form.contact || ""}
|
|
|
|
|
|
onChange={e => setField("contact", e.target.value)}
|
|
|
|
|
|
placeholder="Phone (e.g. 555-123-4567) or email"
|
|
|
|
|
|
/>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-1 col-span-2">
|
|
|
|
|
|
<Label className="text-xs">Comments</Label>
|
|
|
|
|
|
<Input value={form.comments || ""} onChange={e => setField("comments", e.target.value)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-18 17:10:16 -05:00
|
|
|
|
</div>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
)}
|
2026-03-18 17:10:16 -05:00
|
|
|
|
|
2026-03-20 00:28:45 -05:00
|
|
|
|
{formError && <p className="text-sm text-red-600">{formError}</p>}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-2 pt-1">
|
|
|
|
|
|
<Button className="flex-1 bg-primary hover:bg-primary/90" onClick={handleSave} disabled={isSaving}>
|
|
|
|
|
|
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : editingItem ? "Save Changes" : "Add to Inventory"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
|
|
|
|
|
|
</div>
|
2026-03-18 17:10:16 -05:00
|
|
|
|
</div>
|
2026-03-20 00:28:45 -05:00
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
2026-03-18 17:10:16 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|